Riverpod 學習筆記

Tags
Flutter
Series
Published
June 30, 2025
Platform
Medium
Author
Ruby Chu
🪴

Todo

use DeepWiki to get the BLoC dependency structure, finite-state machine in DEV branch
  • draw diagram
use DeepWiki to get Riverpod dependency structure in Refactor branch
  • draw diagram
 

Architecture Differences Between 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 or some_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 a ChangeNotifier 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

  • 整合各狀態處理,可以避免遺漏錯誤處理、尚未讀取到值 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 特色
Riverpod Basics (ProviderScope, StateProvider, StreamProvider, family, autoDispose)
Video preview
FutureProvider, AsyncValue
Video preview
NotifierProvider (Riverpod 2.0)
Video preview

📝 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.
    • This:
    • Awaits the function
    • Returns AsyncValue.data(result) if it succeeds
    • Returns AsyncValue.error(error, stackTrace) if it throws
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