Architecture Differences Between Riverpod
and Provider + ChangeNotifer
Riverpod
and Provider + ChangeNotifer
Riverpod Architecture
some_view.dart
: UI components
some_state.dart
: Contains immutable state definitions (often using freezer) ⇒ For reading/accessing the properties or states
some_view_model.dart
orsome_controller.dart
: Contains business logic ⇒ For performing logic and any functions
some_provider.dart
: Contains provider definitions
Riverpod Core Concepts
Consumer( builder: (context, ref, child) { final userAsync = ref.watch(userProvider); return userAsync.when( loading: () => CircularProgressIndicator(), error: (err, stack) => Text('Error: $err'), data: (user) => UserProfile(user: user), ); }, )
Provider + ChangeNotifier
some_view.dart
: Contains the UI widgets that consume the state
some_view_model.dart
: Contains aChangeNotifier
class that manages the state
Code Generation vs Without
- Naming consistency
// Without code generation final userProvider = Provider<User>((ref) { final auth = ref.watch(authProvider); return User(auth.userId); }); ---- // With code generation @riverpod User user(UserRef ref) { final auth = ref.watch(authProvider); return User(auth.userId); }
- Reduce the effort to manually define providers
- without needing to always extend notifiers from
StateNotifier
, just use_${name}
instead
// Traditional approach final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) { return CounterNotifier(); }); class CounterNotifier extends StateNotifier<int> { CounterNotifier() : super(0); void increment() => state = state + 1; } ---- // Code generation approach with @riverpod annotation @riverpod class Counter extends _$Counter { @override int build() => 0; void increment() => state = state + 1; } // The generator creates counterProvider automatically
- auto generate type-safe processor
// Generated accessors (you don't write these) // Access the provider itself final counterProvider = _$counterProvider; // Access just the notifier (for calling methods) final counterNotifierProvider = _$counterNotifierProvider; // Extension methods for cleaner access in widgets extension CounterProviderExtension on AutoDisposeNotifierProviderRef<Counter, int> { // Type-safe methods for this specific provider }
- automatic error handling (
AsyncValue
)
// Without code generation // 1. Define the state notifier with dependencies class UserRepositoryNotifier extends StateNotifier<AsyncValue<User>> { UserRepositoryNotifier(this.ref) : super(const AsyncValue.loading()) { fetchUser(); } final Ref ref; Future<void> fetchUser() async { state = const AsyncValue.loading(); try { final authService = ref.read(authServiceProvider); final userId = ref.read(userIdProvider); final user = await authService.getUser(userId); state = AsyncValue.data(user); } catch (e, stack) { state = AsyncValue.error(e, stack); } } } // 2. Manually create the provider with proper types final userRepositoryProvider = StateNotifierProvider<UserRepositoryNotifier, AsyncValue<User>>((ref) { return UserRepositoryNotifier(ref); }); ---- // With code generation // Single class with annotation handles everything @riverpod class UserRepository extends _$UserRepository { @override FutureOr<User> build() async { return fetchUser(); } Future<User> fetchUser() async { state = const AsyncValue.loading(); try { final authService = ref.read(authServiceProvider); final userId = ref.read(userIdProvider); return await authService.getUser(userId); } catch (e, stack) { // AsyncValue error handling is built-in throw e; } } } // userRepositoryProvider is automatically generated
AsyncValue
AsyncValue
- 整合各狀態處理,可以避免遺漏錯誤處理、尚未讀取到值 value 為空 crash 的情況
// Creating AsyncValue states final loading = AsyncValue<User>.loading(); final data = AsyncValue<User>.data(User('John')); final error = AsyncValue<User>.error(Exception('Failed'), StackTrace.current); // Using AsyncValue with pattern matching widget = asyncValue.when( loading: () => CircularProgressIndicator(), data: (user) => Text(user.name), error: (error, stack) => Text('Error: $error'), ); // Or with convenient handling methods if (asyncValue.hasValue) { // Access data safely with asyncValue.value } // Or with more concise syntax widget = asyncValue.map( data: (user) => Text(user.value.name), loading: (_) => CircularProgressIndicator(), error: (e) => Text('Error: ${e.error}'), );
when()
: Pattern matching for all three states
map()
: Similar to when but with value objects
hasValue
: Check if data is available
hasError
: Check if in error state
isLoading
: Check if in loading state
🎯 Resources
Riverpod Basics
(ProviderScope
, StateProvider
, StreamProvider
, family
, autoDispose
)

FutureProvider
, AsyncValue

NotifierProvider
(Riverpod 2.0)

📝 Notes
Consumer
vs ConsumerWidget
Consumer | ConsumerWidget |
only a small part of widget is depend on Provider needing reactive state | a full widget class needs reactive state |
Provider
vs StateProvider
vs NotifierProvider
/StateNotifierProvider
Use Case | Provider (read-only/global-like) | StateProvider (simple state) | NotifierProvider (logic + state) |
Constant value / single source of truth | ✅ | ❌ | ❌ |
Store changing state | ❌ | ✅ | ✅ |
Simple toggle logic (e.g., on/off) | ❌ | ✅ | ✅ (if logic needs abstraction) |
Complex logic & actions (e.g., fetch, validate, manage form state) | ❌ | ❌ | ✅ |
Encapsulated methods | ❌ | ❌ | ✅ |
Testability & separation of concerns | ⚠️ Limited | ⚠️ Minimal | ✅ Excellent |
// # Provider // - Expose a constant or config value // - Expose a class or service class ApiService { void fetchSomething() => print('fetching...'); } final apiServiceProvider = Provider<ApiService>((ref) => ApiService());
“Here’s the one and only instance of ApiService, and I’ll get it wherever I need it.”
- If your provider depends on other providers, it will recompute automatically when dependencies change:
ref.watch
vs ref.read
watch
- listening, rebuild if there’s a state change
- inside build()
read
- one-time read
- usually used in event handler
ref.listen
- no rebuild, like ref.watch
- respond to state changes with callback function
- use inside
initState()
,build()
*,onMount()
- suitable for showing dialog
*Careful to not trigger it many times. Cuz build function could be triggered multiple times when running animation
ref.listen( counterProvider, (previous, next) { if (next >= 5) { showDialog( context: context, builder: (context) { return AlertDialog( title: Text('Warning'), content: Text('Counter dangerously high. Consider resetting it.'), actions: [ TextButton( onPressed: () { }, child: Text('OK'), ), ], ); } ) } } )
autoDispose
(dispose that global provider)
- triggered when there’s no value listen to this provider
final counterProvider = StateProvider.autoDispose((ref) => 0);
ref.refresh
& ref.invalidate
rest the state manually
ref.invalidate(counterProvider) - return void - dispose immediately - invalidate the state, causing it to refresh ref.refresh(counterProvider) - return state - equivalent to invalidate state and read it again
family
(for adding extra parameter in the provider)
final AsyncValue<int> counter = ref.watch(counterProvider(startValue ?? 0)); final counterProvider = StreamProvider.family<int, init>((ref, startValue) async { final wsClient = ref.watch(websocketClientProvider); return wsClient.getCounterStream(startValue); });
FutureProvider
- Best used for API calls
final fakeHttpClientProvider = Provider((ref) => FakeHttpClient()); final responseProvider = FutureProvider.autoDispose<String>((ref) async { final httpClient = ref.read(fakeHttpClientProvider); return httpClient.get('some_url'); }); Consumer( builder: (context, ref. child) { final responseProviderAyncValue = ref.watch(responseProvider); return responseProviderAyncValue.map( data: (_) => Text(_.value), loading: const CircularProgressIndicator(), error: (_) => Text( +.error.toString(), style: TextStyle(color: Colors.red), ), ); } )
NotifierProvider
& Notifier
// class CounterNotifier extends Notifier<int> { @override int build() { return 0; // initial state } void increment() => state++; void decrement() => state--; void reset() => state = 0; } final counterProvider = NotifierProvider<CounterNotifier, int>(() => CounterNotifier()); class CounterView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider); // watches state final counter = ref.read(counterProvider.notifier); // accesses logic return Column( children: [ Text('Count: $count'), Row( children: [ ElevatedButton(onPressed: counter.increment, child: Text('+')), ElevatedButton(onPressed: counter.decrement, child: Text('-')), ElevatedButton(onPressed: counter.reset, child: Text('Reset')), ], ), ], ); } }
// Complex State // same way of accessing and watching the state 👆🏻👆🏻 class CounterState { final int count; final bool isEven; CounterState(this.count) : isEven = count % 2 == 0; } class CounterNotifier extends Notifier<CounterState> { @override CounterState build() => CounterState(0); // initial state void increment() => state = CounterState(state.count + 1); }
AsyncValue.guard()
- A shorthand for the try–catch block.
- Awaits the function
- Returns AsyncValue.data(result) if it succeeds
- Returns AsyncValue.error(error, stackTrace) if it throws
This:
state = await AsyncValue.guard(() => api.getUser()); // is the same as below code ⬇️⬇️ try { final user = await api.getUser(); state = AsyncValue.data(user); } catch (e, st) { state = AsyncValue.error(e, st); }
AsyncNotifierProvider
- handle asynchronous state (Future, await, API calls, etc.)
- automatically wraps your state in an
AsyncValue<T>
class UserNotifier extends AsyncNotifier<User> { @override Future<User> build() async { return await api.getUser(); // initial fetch } Future<void> refresh() async { state = const AsyncValue.loading(); // ⬅️ Triggers UI loading callback in when() or maybeWhen() state = await AsyncValue.guard(() => api.getUser()); // ⬅️ Triggers success or error state in when() or maybeWhen() } Future<void> updateName(String name) async { await api.updateUserName(name); state = await AsyncValue.guard(() => api.getUser()); } }
final userProvider = AsyncNotifierProvider<UserNotifier, User>(() => UserNotifier());
class UserView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final userAsync = ref.watch(userProvider); return userAsync.when( loading: () => CircularProgressIndicator(), error: (e, _) => Text('Error: $e'), data: (user) => Column( children: [ Text('Hello, ${user.name}'), ElevatedButton( onPressed: () { ref.read(userProvider.notifier).refresh(); }, child: Text('Refresh'), ), ], ), ); } }
AsyncNotifierProvider
vs FutureProvider
vs StreamProvider
Feature / Use Case | AsyncNotifierProvider | FutureProvider | StreamProvider |
Async type | Future-based logic | One-shot Future | Ongoing Stream |
Provider state type | AsyncValue<T> | AsyncValue <T> | AsyncValue <T> |
Built-in loading/error states | ✅ Yes | ✅ Yes | ✅ Yes |
Where async logic lives | Inside a class with methods ( build() + custom logic) | Inside a single callback | Inside a single callback |
Can trigger manually (e.g. button) | ✅ Yes (via methods like refresh() ) | ❌ No (auto-runs once) | ❌ No (auto-subscribe on watch) |
Supports business logic | ✅ Yes (methods inside the notifier) | ❌ No (logic only in inline closure) | ❌ No (logic only in inline closure) |
Ideal for… | APIs, forms, pagination, refreshable views | Fetch-once data (e.g., user on app open) | Live data (chat, WebSocket, Firebase) |
How to decide | Async + logic (refresh, update, mutate) | One-shot fetch (no custom logic) | Real-time live stream |
when
vs maybeWhen
- used in
AsyncNotifier
,FutureProvider
,StreamProvider
that lets you handle certain states explicitly
Feature | .when() | .maybeWhen() |
Requires all cases? | ✅ Yes (must handle data , loading , error ) | ❌ No (you choose which to handle) |
Safer? | ✅ Compiler enforces completeness | ⚠️ Needs orElse to be safe |
Use for? | Handle all states explicitly | Handle some states + fallback |
// when @override Widget build(BuildContext context, WidgetRef ref) { final userAsync = ref.watch(userProvider); return userAsync.maybeWhen( data: (data) => Text(data.toString()), loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Text('Error: $err'), ); }
// maybeWhen @override Widget build(BuildContext context, WidgetRef ref) { final userAsync = ref.watch(userProvider); return userAsync.maybeWhen( loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Text('Error: $err'), orElse: () => Text('User loaded!'), // fallback if not loading or error ); }
Advanced Application
Custom AsyncValueWidget
for Regular Widget (Optimized Solution for AsyncValue .when()
)
- Define
AsyncValueWidget
// Generic AsyncValueWidget to work with values of type T class AsyncValueWidget<T> extends StatelessWidget { const AsyncValueWidget({Key? key, required this.value, required this.data}) : super(key: key); // input async value final AsyncValue<T> value; // output builder function final Widget Function(T) data; @override Widget build(BuildContext context) { return value.when( data: data, loading: (_) => const Center(child: CircularProgressIndicator()), error: (e, _, __) => Center( child: Text( e.toString(), style: Theme.of(context) .textTheme .headline6! .copyWith(color: Colors.red), ), ), ); } }
AsyncValueWidget
Usage
class ProductScreen extends ConsumerWidget { const ProductScreen({Key? key, required this.productId}) : super(key: key); final String productId; @override Widget build(BuildContext context, WidgetRef ref) { final productAsyncValue = ref.watch(productProvider(productId)); return AsyncValueWidget<Product>( value: productAsyncValue, data: (product) => ProductScreenContents(product: product), ); } }
Custom A for ScrollView Widget (Optimized Solution for AsyncValue .when()
on)
- Define
AsyncValueSilverWidget
class AsyncValueSliverWidget<T> extends StatelessWidget { const AsyncValueSliverWidget( {Key? key, required this.value, required this.data}) : super(key: key); // input async value final AsyncValue<T> value; // output builder function final Widget Function(T) data; @override Widget build(BuildContext context) { return value.when( data: data, loading: (_) => const SliverToBoxAdapter( child: Center(child: CircularProgressIndicator()) ), error: (e, _, __) => SliverToBoxAdapter( child: Center( child: Text( e.toString(), style: Theme.of(context) .textTheme .headline6! .copyWith(color: Colors.red), ), ), ), ); } }
AsyncValueSilverWidget
Usage
class ProductsList extends ConsumerWidget { const ProductsList({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { final productsValue = ref.watch(productsProvider); return AsyncValueSliverWidget<List<Product>>( value: productsValue, data: (products) => SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final product = products[index]; return ProductCard(product: product); }, childCount: products.length, ), ), ); } }
File Structure
- providers
- state
- notifier
- event