Hướng dẫn Riverpod + MVVM trong Flutter: CRUD với AsyncNotifier từ A–Z

QuangHN-Dev

1. Giới thiệu: Tại sao cần kết hợp Riverpod với MVVM?

Bạn đã từng rơi vào tình huống code Flutter của mình ngày càng phình to, khó hiểu và mỗi lần sửa một tính năng lại lo sợ ảnh hưởng đến chỗ khác? Hay bạn tự hỏi state management nên để ở đâu, UI rebuild khi nào và làm sao để test?

Đó là những câu hỏi mà bất kỳ lập trình viên Flutter nào cũng gặp phải khi dự án bắt đầu lớn dần.

State management trong Flutter có gì khó?

Flutter là reactive framework – UI được xây dựng từ state. Khi state thay đổi, UI tự động rebuild. Điều này nghe có vẻ đơn giản, nhưng thực tế lại đặt ra nhiều thách thức:

  • State nằm ở đâu? Trong Widget hay bên ngoài?
  • Làm sao để Widget A có thể cập nhật state của Widget B?
  • Làm sao để tránh rebuild không cần thiết, gây giật lag?
  • Làm sao để code dễ bảo trì, dễ test và dễ mở rộng?

Nếu không có một kiến trúc rõ ràng, bạn sẽ dễ dàng rơi vào tình trạng code “spaghetti” – state được truyền xuống qua constructor, Widget phình to vì chứa cả logic và UI, khó phân biệt đâu là business logic đâu là presentation logic.

MVVM giải quyết vấn đề gì trong tổ chức code?

MVVM (Model-View-ViewModel) là một mô hình kiến trúc phần mềm, giúp tách biệt rõ ràng các thành phần:

Thành phần Vai trò
Model Dữ liệu và business logic (ví dụ: class Product, repository, service)
View Giao diện người dùng (Widget trong Flutter) – hiển thị state và nhận tương tác
ViewModel Cầu nối giữa Model và View – quản lý state của View, xử lý logic UI (không phải business logic)

Nhờ sự tách biệt này:

  • View chỉ lo hiển thị, không chứa logic xử lý.
  • ViewModel chứa state và cách thay đổi state, dễ test.
  • Model chứa dữ liệu và business logic, có thể tái sử dụng.

Tại sao Riverpod là lựa chọn tốt để đồng hành cùng MVVM?

Riverpod là một thư viện state management mạnh mẽ, được xây dựng bởi tác giả của Provider. So với các giải pháp khác, Riverpod nổi bật vì:

  • Type-safe: Bắt lỗi ngay từ compile time.
  • Không phụ thuộc vào Widget tree: Provider có thể được truy cập từ bất kỳ đâu, không cần BuildContext.
  • Hỗ trợ dependency injection: Dễ dàng inject các dependency vào ViewModel.
  • Khả năng kết hợp providers: Dễ dàng kết hợp nhiều nguồn state với nhau.
  • Hỗ trợ async state: Xử lý loading/error/data một cách tự nhiên với AsyncValueAsyncNotifier.

Trong MVVM, Riverpod đóng vai trò là cầu nối giữa View (Widget) và ViewModel – cung cấp ViewModel cho View và cho phép View lắng nghe sự thay đổi state.


2. Cài đặt dependencies

Trước khi bắt đầu, hãy thêm các package cần thiết vào pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1   # Cập nhật phiên bản mới nhất
  equatable: ^2.0.5

dev_dependencies:
  flutter_test:
    sdk: flutter

Chạy flutter pub get để tải về.


3. Hiểu đúng MVVM trong Flutter

Trước khi đi vào code, cần hiểu rõ MVVM trong bối cảnh Flutter – không áp đặt nguyên bản từ Android hay iOS.

Các thành phần: Model – View – ViewModel

Model:

  • Đại diện cho dữ liệu và business logic.
  • Có thể là các class đơn giản (DTO), service gọi API, repository lưu trữ dữ liệu.
  • Không phụ thuộc vào UI.

View:

  • Là các Widget Flutter.
  • Nhiệm vụ duy nhất: hiển thị state từ ViewModel và gửi sự kiện tương tác người dùng lên ViewModel.
  • Không chứa logic xử lý, không gọi API trực tiếp, không thao tác dữ liệu.

ViewModel:

  • Quản lý state mà View cần hiển thị.
  • Cung cấp các action (method) để View gọi khi có tương tác.
  • Nhận dữ liệu từ Model (service/repository) và biến đổi thành state phù hợp cho View.
  • Không biết gì về Widget – là class Dart thuần.

Vai trò của ViewModel trong Flutter (khác với Android như thế nào)

Trên Android (native), ViewModel thường gắn với vòng đời của Activity/Fragment và được giữ khi xoay màn hình.

Trong Flutter, vì mọi thứ đều là Widget và vòng đời khác, ViewModel là một class Dart thuần – không extends StatefulWidget, không extends ChangeNotifier (nếu dùng Riverpod).

ViewModel trong Flutter với Riverpod thường được xây dựng dưới dạng AsyncNotifier (hoặc Notifier) – chứa state và các phương thức thay đổi state.

Mối quan hệ giữa Widget (View) và ViewModel

[View] ---(watch)---> [ViewModel] ---(gọi)--> [Model/Repository]
   |                        |
   |---(gọi action)-------->|
   |                        |
   |<---(state mới)---------|
  • View watch ViewModel để lắng nghe state.
  • Khi người dùng tương tác, View gọi một action trên ViewModel.
  • ViewModel cập nhật state → Riverpod thông báo cho View → View rebuild với state mới.

4. Riverpod – Công cụ kết nối View và ViewModel

Provider là gì và tại sao dùng cho ViewModel?

Provider trong Riverpod là một đối tượng “lưu trữ” một giá trị và cung cấp cách để truy xuất giá trị đó. Provider có thể:

  • Được tạo một lần và tồn tại trong suốt vòng đời app.
  • Có thể phụ thuộc vào provider khác.
  • Tự động quản lý vòng đời.

Trong MVVM, mỗi ViewModel thường được expose qua một Provider, giúp View có thể dễ dàng lấy được instance của ViewModel và lắng nghe state.

Các loại Provider phù hợp với ViewModel

Loại Provider Khi nào dùng
Provider Giá trị chỉ đọc, không thay đổi (ví dụ: repository, service)
StateProvider State đơn giản (int, String, enum) – ít dùng trong MVVM phức tạp
NotifierProvider State phức tạp, có action thay đổi state, đồng bộ (không async)
AsyncNotifierProvider (khuyến nghị) State phức tạp, có action, bất đồng bộ (gọi API, CRUD)
FutureProvider Một lần fetch dữ liệu bất đồng bộ, không có action

Lưu ý: Riverpod 2.x giới thiệu NotifierAsyncNotifier thay thế cho StateNotifier cũ. Với các tác vụ async (gọi API, CRUD), luôn dùng AsyncNotifier để có sẵn loading/error state.

Sự khác biệt giữa Riverpod và GetIt/Service Locator

Riverpod Provider GetIt (Service Locator)
Cách lấy Qua ref.watch / ref.read Qua `GetIt.I.get
()`
Phụ thuộc Có thể watch provider khác, tự động cập nhật Thủ công, không tự động
Vòng đời Tự động quản lý (autoDispose) Phải tự dispose
Testing Dễ dàng override provider Phải override registration
Reactive Có – UI tự rebuild khi state thay đổi Không – phải tự gọi setState

Riverpod không chỉ là một service locator, mà còn là một reactive state management – View tự động rebuild khi state thay đổi.


5. Thiết kế ViewModel với Riverpod (Step-by-Step)

Bây giờ, chúng ta sẽ đi vào thực hành. Giả sử ta đang xây dựng màn hình quản lý danh sách sản phẩm (Product List) với các chức năng:

  • Hiển thị danh sách sản phẩm
  • Thêm sản phẩm mới
  • Sửa tên sản phẩm
  • Xóa sản phẩm
  • Lọc sản phẩm theo tên (local)

Bước 1: Xác định Model (Product)

// models/product.dart
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';

@immutable
class Product extends Equatable {
  final String id;
  final String name;
  final double price;

  const Product({
    required this.id,
    required this.name,
    required this.price,
  });

  Product copyWith({String? id, String? name, double? price}) {
    return Product(
      id: id ?? this.id,
      name: name ?? this.name,
      price: price ?? this.price,
    );
  }

  @override
  List<Object?> get props => [id, name, price];
}

Bước 2: Xác định Repository (business logic)

Repository chứa các method gọi API (hoặc giả lập). Đây là business logic, không phải UI logic.

// repositories/product_repository.dart
import 'package:riverpod/riverpod.dart';
import '../models/product.dart';

/// Provider cho repository
final productRepositoryProvider = Provider
<ProductRepository>((ref) {
  return ProductRepository();
});

class ProductRepository {
  // Giả lập database
  final List
<Product> _products = [
    const Product(id: '1', name: 'Laptop', price: 15000000),
    const Product(id: '2', name: 'Chuột không dây', price: 500000),
    const Product(id: '3', name: 'Bàn phím cơ', price: 1200000),
  ];

  Future<List<Product>> getProducts() async {
    // Giả lập độ trễ mạng
    await Future.delayed(const Duration(milliseconds: 500));
    return _products;
  }

  Future
<Product> addProduct(Product product) async {
    await Future.delayed(const Duration(milliseconds: 500));
    final newProduct = product.copyWith(id: DateTime.now().millisecondsSinceEpoch.toString());
    _products.add(newProduct);
    return newProduct;
  }

  Future
<Product> updateProduct(Product product) async {
    await Future.delayed(const Duration(milliseconds: 500));
    final index = _products.indexWhere((p) => p.id == product.id);
    if (index != -1) {
      _products[index] = product;
      return product;
    }
    throw Exception('Sản phẩm không tồn tại');
  }

  Future
<void> deleteProduct(String id) async {
    await Future.delayed(const Duration(milliseconds: 500));
    _products.removeWhere((p) => p.id == id);
  }
}

Bước 3: Xây dựng ViewModel với AsyncNotifier

Vì các action đều là async (gọi repository), ta dùng AsyncNotifier. State của nó là AsyncValue<List<Product>> – tự động quản lý loading, data, error.

// viewmodels/product_viewmodel.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/product.dart';
import '../repositories/product_repository.dart';

class ProductViewModel extends AsyncNotifier<List<Product>> {
  late final ProductRepository _repository;

  @override
  Future<List<Product>> build() async {
    // Inject repository
    _repository = ref.watch(productRepositoryProvider);
    // Load dữ liệu ban đầu
    return await _loadProducts();
  }

  Future<List<Product>> _loadProducts() async {
    try {
      return await _repository.getProducts();
    } catch (e) {
      // AsyncNotifier tự chuyển thành AsyncError
      throw Exception('Không thể tải danh sách sản phẩm: $e');
    }
  }

  /// Thêm sản phẩm
  Future
<void> addProduct(String name, double price) async {
    // Chuyển sang trạng thái loading
    state = const AsyncLoading();

    try {
      final newProduct = await _repository.addProduct(
        Product(id: '', name: name, price: price),
      );
      // Lấy danh sách hiện tại (nếu có)
      final currentList = state.value ?? [];
      state = AsyncData([...currentList, newProduct]);
    } catch (e, stack) {
      state = AsyncError(e, stack);
    }
  }

  /// Cập nhật sản phẩm
  Future
<void> updateProduct(Product product) async {
    state = const AsyncLoading();

    try {
      final updated = await _repository.updateProduct(product);
      final currentList = state.value ?? [];
      state = AsyncData(
        currentList.map((p) => p.id == product.id ? updated : p).toList(),
      );
    } catch (e, stack) {
      state = AsyncError(e, stack);
    }
  }

  /// Xóa sản phẩm
  Future
<void> deleteProduct(String id) async {
    state = const AsyncLoading();

    try {
      await _repository.deleteProduct(id);
      final currentList = state.value ?? [];
      state = AsyncData(currentList.where((p) => p.id != id).toList());
    } catch (e, stack) {
      state = AsyncError(e, stack);
    }
  }
}

/// Provider cho ViewModel, sử dụng autoDispose để tự hủy khi không còn ai watch
final productViewModelProvider = AsyncNotifierProvider.autoDispose<ProductViewModel, List<Product>>(
  ProductViewModel.new,
);

/// Provider riêng cho filter (state đơn giản, không async)
final filterProvider = StateProvider
<String>((ref) => '');

Giải thích:

  • build() trả về Future<List<Product>> – Riverpod tự động wrap thành AsyncValue.
  • Mỗi action đều set state = const AsyncLoading() trước khi gọi API, sau đó set AsyncData hoặc AsyncError – UI sẽ tự động phản ánh.
  • state.value lấy dữ liệu hiện tại (nếu có) – an toàn vì chỉ dùng trong try block.
  • Filter được tách riêng thành StateProvider vì nó là UI state, không liên quan đến async data.

6. Kết nối ViewModel vào Widget (View)

Cấu trúc View

// screens/product_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../viewmodels/product_viewmodel.dart';
import '../models/product.dart';

class ProductListScreen extends ConsumerWidget {
  const ProductListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Lắng nghe async state
    final asyncProducts = ref.watch(productViewModelProvider);
    final filter = ref.watch(filterProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Quản lý sản phẩm'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: () => _showAddDialog(context, ref),
          ),
        ],
      ),
      body: Column(
        children: [
          _buildSearchBar(ref, filter),
          Expanded(
            child: asyncProducts.when(
              loading: () => const Center(child: CircularProgressIndicator()),
              error: (err, stack) => Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text('Lỗi: $err'),
                    ElevatedButton(
                      onPressed: () => ref.invalidate(productViewModelProvider),
                      child: const Text('Thử lại'),
                    ),
                  ],
                ),
              ),
              data: (products) => _buildProductList(products, filter, ref),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSearchBar(WidgetRef ref, String filter) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: TextField(
        decoration: const InputDecoration(
          hintText: 'Tìm kiếm sản phẩm...',
          prefixIcon: Icon(Icons.search),
          border: OutlineInputBorder(),
        ),
        onChanged: (value) => ref.read(filterProvider.notifier).state = value,
        controller: TextEditingController(text: filter),
      ),
    );
  }

  Widget _buildProductList(List
<Product> products, String filter, WidgetRef ref) {
    final filtered = products
        .where((p) => p.name.toLowerCase().contains(filter.toLowerCase()))
        .toList();

    if (filtered.isEmpty) {
      return const Center(child: Text('Không có sản phẩm nào'));
    }

    return ListView.builder(
      itemCount: filtered.length,
      itemBuilder: (context, index) {
        final product = filtered[index];
        return ListTile(
          title: Text(product.name),
          subtitle: Text('${product.price.toStringAsFixed(0)} VND'),
          trailing: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              IconButton(
                icon: const Icon(Icons.edit),
                onPressed: () => _showEditDialog(context, ref, product),
              ),
              IconButton(
                icon: const Icon(Icons.delete),
                onPressed: () {
                  ref.read(productViewModelProvider.notifier).deleteProduct(product.id);
                },
              ),
            ],
          ),
        );
      },
    );
  }

  // Dialog thêm sản phẩm (có xử lý dispose controller)
  void _showAddDialog(BuildContext context, WidgetRef ref) {
    final nameController = TextEditingController();
    final priceController = TextEditingController();

    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (dialogContext) {
        return AlertDialog(
          title: const Text('Thêm sản phẩm'),
          content: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(
                controller: nameController,
                decoration: const InputDecoration(labelText: 'Tên sản phẩm'),
              ),
              TextField(
                controller: priceController,
                decoration: const InputDecoration(labelText: 'Giá (VND)'),
                keyboardType: TextInputType.number,
              ),
            ],
          ),
          actions: [
            TextButton(
              onPressed: () {
                nameController.dispose();
                priceController.dispose();
                Navigator.pop(dialogContext);
              },
              child: const Text('Hủy'),
            ),
            ElevatedButton(
              onPressed: () async {
                final name = nameController.text.trim();
                final price = double.tryParse(priceController.text.trim()) ?? 0;
                if (name.isEmpty || price <= 0) {
                  ScaffoldMessenger.of(dialogContext).showSnackBar(
                    const SnackBar(content: Text('Vui lòng nhập đầy đủ thông tin')),
                  );
                  return;
                }
                // Gọi action
                await ref.read(productViewModelProvider.notifier).addProduct(name, price);
                nameController.dispose();
                priceController.dispose();
                if (dialogContext.mounted) Navigator.pop(dialogContext);
              },
              child: const Text('Thêm'),
            ),
          ],
        );
      },
    );
  }

  // Dialog sửa sản phẩm
  void _showEditDialog(BuildContext context, WidgetRef ref, Product product) {
    final nameController = TextEditingController(text: product.name);
    final priceController = TextEditingController(text: product.price.toString());

    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (dialogContext) {
        return AlertDialog(
          title: const Text('Sửa sản phẩm'),
          content: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(
                controller: nameController,
                decoration: const InputDecoration(labelText: 'Tên sản phẩm'),
              ),
              TextField(
                controller: priceController,
                decoration: const InputDecoration(labelText: 'Giá (VND)'),
                keyboardType: TextInputType.number,
              ),
            ],
          ),
          actions: [
            TextButton(
              onPressed: () {
                nameController.dispose();
                priceController.dispose();
                Navigator.pop(dialogContext);
              },
              child: const Text('Hủy'),
            ),
            ElevatedButton(
              onPressed: () async {
                final name = nameController.text.trim();
                final price = double.tryParse(priceController.text.trim()) ?? 0;
                if (name.isEmpty || price <= 0) {
                  ScaffoldMessenger.of(dialogContext).showSnackBar(
                    const SnackBar(content: Text('Vui lòng nhập đầy đủ thông tin')),
                  );
                  return;
                }
                final updatedProduct = product.copyWith(name: name, price: price);
                await ref.read(productViewModelProvider.notifier).updateProduct(updatedProduct);
                nameController.dispose();
                priceController.dispose();
                if (dialogContext.mounted) Navigator.pop(dialogContext);
              },
              child: const Text('Lưu'),
            ),
          ],
        );
      },
    );
  }
}

Giải thích:

  • Sử dụng ConsumerWidget – có sẵn ref để watch/read.
  • asyncProducts.when() xử lý 3 trạng thái một cách rõ ràng.
  • Khi có lỗi, cung cấp nút “Thử lại” bằng cách gọi ref.invalidate(provider) – Riverpod sẽ tự động gọi lại build().
  • Các TextEditingController được dispose trong onPressed của nút Hủy hoặc Thêm/Lưu để tránh memory leak.
  • Có validation input và hiển thị snackbar khi nhập sai.

7. Chạy thử và giải thích luồng hoạt động

  1. Mở màn hình: build() của ProductViewModel chạy → gọi _loadProducts() → state là AsyncLoading → UI hiển thị CircularProgressIndicator.
  2. Load thành công: state thành AsyncData([...]) → UI hiển thị danh sách sản phẩm.
  3. Nhập từ khóa tìm kiếm: filterProvider thay đổi → UI rebuild với danh sách đã lọc (không gọi API).
  4. Thêm sản phẩm: Gọi addProduct() → state = AsyncLoading → UI hiển thị loading (có thể thêm overlay). Khi thành công → AsyncData với danh sách mới → UI cập nhật.
  5. Lỗi: Nếu repository throw exception, state = AsyncError → UI hiển thị thông báo lỗi.

8. Mở rộng: Tổ chức nhiều ViewModel và phụ thuộc

Chia sẻ dữ liệu giữa các ViewModel

ViewModel không nên biết về nhau trực tiếp. Dùng Provider để chia sẻ dữ liệu.

// user_provider.dart
final currentUserProvider = Provider<User?>((ref) => null);

// cart_viewmodel.dart
class CartViewModel extends AsyncNotifier<List<CartItem>> {
  @override
  Future<List<CartItem>> build() async {
    final user = ref.watch(currentUserProvider);
    if (user == null) return [];
    return await _repository.getCart(user.id);
  }
}

Sử dụng family cho nhiều instance

final productDetailProvider = AsyncNotifierProvider.family<ProductDetailViewModel, ProductDetailState, String>(
  (ref, productId) => ProductDetailViewModel(productId: productId),
);

Cấu trúc thư mục gợi ý

lib/
├── core/
│   ├── di/              # Providers toàn cục
│   └── network/         # Dio, interceptor
├── features/
│   └── product/
│       ├── models/
│       │   └── product.dart
│       ├── repositories/
│       │   └── product_repository.dart
│       ├── viewmodels/
│       │   └── product_viewmodel.dart
│       └── screens/
│           └── product_list_screen.dart
└── main.dart

9. Những lưu ý khi áp dụng Riverpod + MVVM

Không để business logic quá nặng trong ViewModel

ViewModel chỉ nên chứa UI logic – cách state thay đổi để phản ứng với tương tác người dùng. Business logic (gọi API, tính toán phức tạp, validation) nên để ở Repository hoặc UseCase.

// ❌ Sai: ViewModel gọi API trực tiếp
class ProductViewModel extends AsyncNotifier<List<Product>> {
  Future
<void> addProduct(String name, double price) async {
    final response = await http.post(...); // Business logic trong VM
    // ...
  }
}

// ✅ Đúng: ViewModel gọi Repository
class ProductViewModel extends AsyncNotifier<List<Product>> {
  Future
<void> addProduct(String name, double price) async {
    final newProduct = await _repository.addProduct(...);
    // ...
  }
}

Tránh rebuild không cần thiết

  • Dùng select để chỉ watch phần state cần thiết.
  • Tách Widget con để mỗi Widget chỉ watch state của riêng nó.
  • Tránh dùng ref.watch trong build của Widget cha nếu không cần toàn bộ state.

Ví dụ tối ưu với select:

final isLoading = ref.watch(productViewModelProvider.select((state) => state.isLoading));

Quản lý vòng đời và cleanup

final productProvider = AsyncNotifierProvider.autoDispose<ProductViewModel, List<Product>>(
  ProductViewModel.new,
);

// Hoặc trong Notifier:
class ProductViewModel extends AsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() async {
    ref.onDispose(() {
      // Dọn dẹp tài nguyên nếu có
    });
    return await _loadProducts();
  }
}

10. Tổng kết

Lợi ích của việc kết hợp Riverpod và MVVM

Lợi ích Mô tả
Tách biệt rõ ràng View chỉ lo UI, ViewModel quản lý state, Model xử lý dữ liệu
Dễ test ViewModel là class Dart thuần, có thể test độc lập
Dễ bảo trì Mỗi thành phần có trách nhiệm rõ ràng, dễ tìm và sửa lỗi
Tối ưu rebuild Riverpod hỗ trợ select và tách Widget con
Hỗ trợ async AsyncNotifier xử lý loading/error một cách tự nhiên
Dependency Injection Dễ dàng inject repository/service vào ViewModel

Gợi ý các chủ đề mở rộng


11. Câu hỏi thường gặp (FAQ)

1. ViewModel trong Flutter có phải là StatefulWidget?

Không. ViewModel là một class Dart thuần, không phải Widget. Nó chứa state và logic xử lý, được cung cấp qua Provider. Widget (View) chỉ lắng nghe và hiển thị.

2. Có nhất thiết phải dùng AsyncNotifier không?

Không bắt buộc, nhưng đây là cách được khuyến khích nhất trong Riverpod cho MVVM khi có async (gọi API). Nếu state hoàn toàn đồng bộ, bạn có thể dùng Notifier.

3. Tôi có thể dùng Riverpod mà không cần MVVM không?

Có, Riverpod độc lập với kiến trúc. MVVM chỉ là một cách tổ chức code. Bài viết này tập trung vào cách kết hợp để tối ưu hóa maintainability.

4. Có nên dùng ChangeNotifierProvider trong MVVM không?

Hạn chế. ChangeNotifier khuyến khích mutable state, không an toàn và khó test. Dùng Notifier hoặc AsyncNotifier (mới trong Riverpod 2.x) tốt hơn.

5. AsyncNotifier khác gì với FutureProvider?

FutureProvider chỉ fetch một lần, không có action để thay đổi state từ UI. AsyncNotifier vừa fetch dữ liệu, vừa expose các method (add, update, delete) để UI tương tác.

6. Làm thế nào để test ViewModel với Riverpod?

Bạn có thể dùng ProviderContainer để override provider và kiểm tra state. Chi tiết xem bài viết Testing Riverpod trong MVVM.

Chia sẻ bài viết này
Hãy để lại bình luận