Riverpod trong Flutter: Quản lý State chuyên nghiệp từ Provider cơ bản đến Riverpod Generator

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Bài này đi thẳng vào code — không lý thuyết dài dòng. Đang dùng Provider package cũ thấy state management ngày càng rối? Hay bắt đầu project mới và phân vân chọn gì? Riverpod 2.x giải quyết cả hai. Cùng xem.

Bắt đầu ngay: Cài Riverpod và chạy trong 5 phút

Thêm dependency vào pubspec.yaml:

dependencies:
  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5

dev_dependencies:
  riverpod_generator: ^2.4.3
  build_runner: ^2.4.9

Wrap toàn bộ app với ProviderScope — bước này bắt buộc, thiếu là crash ngay:

void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}

Tạo provider đầu tiên và dùng luôn:

final counterProvider = StateProvider<int>((ref) => 0);

class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Column(
      children: [
        Text('Count: $count'),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).state++,
          child: Text('Tăng'),
        ),
      ],
    );
  }
}

ConsumerWidget thay vì StatelessWidget — đó là điểm khác biệt cốt lõi so với Provider package cũ. Xong quick start, đi vào phần thực chất hơn.

Các loại Provider và khi nào dùng cái nào

StateProvider — State đơn giản

Giới hạn với primitive: bool, int, String, enum. Đừng nhét List hay object phức tạp vào đây — đó là việc của NotifierProvider:

final isDarkModeProvider = StateProvider<bool>((ref) => false);
final selectedTabProvider = StateProvider<int>((ref) => 0);

FutureProvider — Gọi API, đọc file async

Mình dùng cái này nhiều nhất khi fetch data từ server. Điểm hay là nó tự handle loading/error state — không cần viết thêm boilerplate:

final userListProvider = FutureProvider<List<User>>((ref) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.fetchAll();
});

// Trong widget
class UserList extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncUsers = ref.watch(userListProvider);
    return asyncUsers.when(
      data: (users) => ListView.builder(
        itemCount: users.length,
        itemBuilder: (_, i) => ListTile(title: Text(users[i].name)),
      ),
      loading: () => CircularProgressIndicator(),
      error: (e, _) => Text('Lỗi: $e'),
    );
  }
}

NotifierProvider — Business logic phức tạp

StateNotifierProvider đã bị deprecated từ Riverpod 2.x — NotifierProvider là thứ thay thế nó. Rule đơn giản: mọi logic xử lý state nằm ở đây, không nhét vào widget:

class CartNotifier extends Notifier<List<CartItem>> {
  @override
  List<CartItem> build() => [];

  void addItem(CartItem item) {
    state = [...state, item];
  }

  void removeItem(String id) {
    state = state.where((item) => item.id != id).toList();
  }

  double get total => state.fold(0, (sum, item) => sum + item.price);
}

final cartProvider = NotifierProvider<CartNotifier, List<CartItem>>(
  CartNotifier.new,
);

AsyncNotifierProvider — Async state có side effects

Login, submit form, checkout — những tình huống vừa cần tải data vừa trigger side effect. AsyncNotifierProvider xử lý đúng cái này:

class AuthNotifier extends AsyncNotifier<User?> {
  @override
  Future<User?> build() async => null;

  Future<void> login(String email, String password) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() =>
      ref.read(authRepositoryProvider).login(email, password),
    );
  }

  void logout() => state = const AsyncValue.data(null);
}

Nâng cao: Riverpod Generator — Viết ít code, ít lỗi hơn

Mình từng refactor codebase 50K lines và bài học đắt nhất là phải có test coverage tốt trước khi bắt đầu. Riverpod Generator giúp rất nhiều ở đây — nó tự sinh boilerplate, giảm rủi ro khi refactor, và code rõ ý định hơn nhiều so với viết tay.

Chạy build_runner ở watch mode trong suốt quá trình dev:

dart run build_runner watch --delete-conflicting-outputs

Viết provider với annotation, phần còn lại build_runner lo:

// user_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'user_provider.g.dart'; // file này được tự generate

@riverpod
Future<List<User>> userList(UserListRef ref) async {
  return ref.watch(userRepositoryProvider).fetchAll();
}

// Provider có parameter (thay thế family)
@riverpod
Future<User> userById(UserByIdRef ref, String id) async {
  return ref.watch(userRepositoryProvider).getById(id);
}

// Stateful provider với class
@riverpod
class Cart extends _$Cart {
  @override
  List<CartItem> build() => [];

  void add(CartItem item) => state = [...state, item];
  void remove(String id) => state = state.where((i) => i.id != id).toList();
}

keepAlive — Cache provider sau khi widget dispose

Mặc định với Generator, provider tự dispose khi hết listener. Dùng keepAlive: true cho data cần cache như config, user session:

@Riverpod(keepAlive: true)
Future<AppConfig> appConfig(AppConfigRef ref) async {
  return ConfigService().load(); // Chỉ load một lần duy nhất
}

Tips thực tế từ dự án production

1. Override provider trong tests — lý do mình chọn Riverpod

So với GetX hay BLoC, testing với Riverpod clean hơn nhiều. Không cần mock framework. Không cần DI container cồng kềnh:

testWidgets('hiển thị danh sách user', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userListProvider.overrideWith((ref) async => [
          User(id: '1', name: 'Nguyễn Văn Test'),
        ]),
      ],
      child: MaterialApp(home: UserList()),
    ),
  );

  await tester.pumpAndSettle();
  expect(find.text('Nguyễn Văn Test'), findsOneWidget);
});

2. ref.invalidate() để refresh data

ElevatedButton(
  onPressed: () async {
    await ref.read(cartProvider.notifier).checkout();
    ref.invalidate(orderHistoryProvider); // Force fetch lại
    ref.invalidate(cartProvider);          // Reset cart về initial state
  },
  child: Text('Thanh toán'),
)

3. Tránh watch trong callback — lỗi hay gặp nhất

// ❌ Sai — throw exception ngay
ElevatedButton(
  onPressed: () {
    final user = ref.watch(userProvider); // KHÔNG watch trong callback
  },
);

// ✅ Đúng — dùng read trong callback
ElevatedButton(
  onPressed: () {
    final user = ref.read(userProvider);
  },
);

4. Lắng nghe side effect với listenManual

Trong ConsumerStatefulWidget, dùng ref.listenManualinitState để react với thay đổi state mà không trigger rebuild toàn widget:

@override
void initState() {
  super.initState();
  ref.listenManual(authProvider, (previous, next) {
    // Navigate khi auth state thay đổi
    if (next.valueOrNull == null) {
      Navigator.pushReplacementNamed(context, '/login');
    }
  });
}

5. Tổ chức folder theo feature, không theo type

lib/
  features/
    auth/
      providers/    ← auth_provider.dart + auth_provider.g.dart
      models/
      repositories/
      screens/
    cart/
      providers/
      models/
    ...

Structure này scale tốt hơn nhiều so với gom tất cả providers vào một folder. Cần xóa một feature? Xóa cả folder là xong — không phải đi săn file rải rác khắp nơi.

Đang dùng Provider package cũ? Migrate từng feature một — đừng làm bulk migration. Từ đợt refactor 50K lines đó: làm nhỏ từng phần, test đầy đủ, rollback dễ hơn gấp 10 lần so với đổi cả cục rồi debug không biết lỗi ở đâu.

Share: