mini_riverpod — Usage Guide
日本語版: README-JA.md
mini_riverpod is a lightweight reimplementation of the Riverpod experience.
It avoids complaints like "too many options / codegen required" and consists of a single-file core and a thin Flutter binding.
- No code generation: No codegen needed for family/mutations/concurrency control
- Simplified API: Update with
ref.invoke(provider.method()), simple DI withScope - Future/Stream integration: Handle both
FutureandStreamwith a singleAsyncProvider, with strict subscription/disposal management - autoDispose: Auto-dispose when unsubscribed (with delay option)
- Concurrency control: Standard support for
concurrent / queue / restart / dropLatest
Table of Contents
- mini_riverpod — Usage Guide
- Table of Contents
- First Steps
- Provider (Synchronous)
- AsyncProvider (Asynchronous/Future/Stream)
- State Updates (Mutations) and Concurrency Control
- invalidate / refresh
- family (with parameters) = Subclass +
args - Scope (DI/fallback)
- autoDispose / Lifecycle
- Flutter API
- Writing Tests
- Migration Notes (from Riverpod)
- Troubleshooting
- Small Sample (Comprehensive)
- Appendix: Main API Reference
First Steps
// 1) Wrap your app with ProviderScope
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
// 2) Define a Provider
final counterProvider = Provider<int>((ref) => 0);
// 3) Watch from UI
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Directionality(
textDirection: TextDirection.ltr,
child: Center(child: Text('$count')),
);
}
}
Provider((ref) => ...)(synchronous)AsyncProvider<T>((ref) async => ...)(asynchronous)- From UI, subscribe with
WidgetRef.watch(provider).
Provider (Synchronous)
/// Provides a synchronous value
final configProvider = Provider<Config>((ref) {
return Config(apiBaseUrl: 'https://example.com');
});
/// To update, use Provider methods (see "Mutations" below)
- The
Providerconstructor takes a builder function as the first argument. - Using
Provider.args()allows you to subclass and add methods (see below).
AsyncProvider (Asynchronous/Future/Stream)
Returning a Future
final currentUser = AsyncProvider<User>((ref) async {
final api = ref.watch(apiProvider);
final u = await api.me();
return u;
});
// UI side
class Header extends ConsumerWidget {
const Header({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final av = ref.watch(currentUser); // AsyncValue<User>
return switch (av) {
AsyncLoading() => const Text('Loading...'),
AsyncError(:final error) => Text('Error: $error'),
AsyncData(:final value) => Text('Hello, ${value.name}'),
};
}
}
Handling Streams (ref.emit(stream))
final liveUser = AsyncProvider<User>((ref) {
final api = ref.watch(apiProvider);
final stream = api.userStream();
// Subscribe (previous subscription is strictly cancelled on each build)
ref.emit(stream);
// Synchronous value for initial display (optional)
return const User(name: 'Loading...');
}, autoDispose: true, autoDisposeDelay: const Duration(milliseconds: 250));
future Selector
You can directly await a Future<T> with ref.watch(myAsync.future).
final userFuture = currentUser.future; // Provider<Future<User>>
AsyncValue<T>is a sealed class withAsyncLoading / AsyncData / AsyncError.
Awhenmethod is not provided. Please use pattern matching (switch) orischecks.
State Updates (Mutations) and Concurrency Control
Basic: mutation + mutate + ref.invoke
- Define mutations as methods of the provider.
- Mutation state (
Idle / Pending / Success / Error) can be monitored bywatching aMutationToken<T>. - From UI/logic side, execute with
ref.invoke(provider.method(...)).
class UserProvider extends AsyncProvider<User?> {
UserProvider() : super.args(null);
@override
FutureOr<User?> build(Ref<AsyncValue<User?>> ref) async {
final api = ref.watch(apiProvider);
return api.me();
}
// 1) Mutation token (for monitoring)
late final renameMut = mutation<void>(#rename);
// 2) Execution body (returns Call<void, AsyncValue<User?>>)
Call<void, AsyncValue<User?>> rename(String newName) => mutate(
renameMut,
(ref) async {
// Optimistic update
final cur = ref.watch(this).valueOrNull;
ref.state = AsyncData((cur ?? const User()).copyWith(name: newName), isRefreshing: true);
// Server update
final api = ref.watch(apiProvider);
await api.rename(newName);
// Reflect final value
final fresh = await api.me();
ref.state = AsyncData(fresh);
},
concurrency: Concurrency.restart, // ← Concurrency control (see below)
);
}
final userProvider = UserProvider();
// UI
class RenameButton extends ConsumerWidget {
const RenameButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final m = ref.watch(userProvider.renameMut); // MutationState<void>
return ElevatedButton(
onPressed: switch (m) {
PendingMutationState() => null, // Disable while executing, etc.
_ => () async {
try {
await ref.invoke(userProvider.rename('Alice'));
} catch (e) {
// CancelledMutation/DroppedMutation may be thrown with restart/dropLatest
}
}
},
child: Text(switch (m) {
IdleMutationState() => 'Rename',
PendingMutationState() => 'Renaming...',
SuccessMutationState() => 'Renamed!',
ErrorMutationState(:final error) => 'Retry ($error)',
}),
);
}
}
Concurrency Control
concurrent(default): Allow all concurrent executionsqueue: Serial execution (FIFO). Subsequent executions continue even if there's an error in the middlerestart: Only the latest is valid. Old executions throwCancelledMutationand terminatedropLatest: Immediately drop new ones while executing. ThrowsDroppedMutationto the caller
For any of these, you can choose to swallow or notify with
try/catchon theref.invoke(...)side.
ProviderContainer.dispose()does not stop running mutations, but
queue / restart / dropLatestthat complete after disposal are treated asCancelledMutation.
concurrentcompletes.
invalidate / refresh
-
ref.invalidate(provider, keepPrevious: true/false)- Works for both synchronous and asynchronous
-
For asynchronous, if
keepPrevious: trueand there is a previous value,
it transitions toAsyncData(isRefreshing: true)(if there's no previous value, it becomesAsyncLoading). -
ref.refresh(provider)(for synchronous: immediately recalculates and returns the value) -
await ref.refreshValue(asyncProvider, keepPrevious: true/false)(for asynchronous: returns the value when complete)
// Example: Pull to refresh
onPressed: () async {
await ref.refreshValue(currentUser, keepPrevious: true);
}
family (with parameters) = Subclass + args
There is no dedicated family type. Parameters are handled by subclassing Provider and defining equality with the args constructor argument.
class ProductProvider extends AsyncProvider<List<Product>> {
ProductProvider({this.search = ''}) : super.args((search,));
final String search;
@override
FutureOr<List<Product>> build(Ref<AsyncValue<List<Product>>> ref) async {
final api = ref.watch(apiProvider);
return api.search(q: search);
}
// Even for parameterized providers, mutations need a unique symbol
late final addAllMut = mutation<void>(#addAllToCart);
Call<void, AsyncValue<List<Product>>> addAllToCart() =>
mutate(addAllMut, (ref) async { /* ... */ });
}
// Usage
final homeProducts = ProductProvider(); // search=''
final jeansProducts = ProductProvider(search: 'jeans');
ref.watch(homeProducts);
ref.watch(jeansProducts);
// Mutation
await ref.invoke(jeansProducts.addAllToCart());
family-like override (Testing/DI)
Even without ProviderFamily, if you prepare a function "parameter → Provider instance",
you can use overrideWith / overrideWithValue with the same feeling as regular Providers.
class ProductById extends Provider<Product> {
ProductById(this.id) : super.args((id,));
final String id;
@override
Product build(Ref<Product> ref) {
final repo = ref.watch(productRepoProvider);
return repo.fetch(id);
}
}
// family-like factory
Provider<Product> productByIdProvider(String id) => ProductById(id);
// Override in ProviderScope / ProviderContainer
final container = ProviderContainer(
overrides: [
productByIdProvider('a')
.overrideWithValue(const Product(id: 'a', name: 'stub')),
],
);
expect(container.read(productByIdProvider('a')).name, 'stub');
There is no feature like Riverpod's
overrideWith((arg) => ...)to "replace all args at once".
Please create Provider instances for the needed arguments and override them.
Scope (DI/fallback)
Inject values (often parameterized Provider instances) into scope tokens, and retrieve them from ref.scope(token) in the subtree below.
class ProductProvider extends AsyncProvider<List<Product>> {
ProductProvider({this.search = ''}) : super.args((search,));
final String search;
// Scope definition (required)
static final fallback = Scope<ProductProvider>.required('product.fallback');
@override
FutureOr<List<Product>> build(Ref<AsyncValue<List<Product>>> ref) async { /* ... */ }
}
// Inject in ProviderScope
ProviderScope(
overrides: [
ProductProvider.fallback.overrideWithValue(ProductProvider(search: 'jeans')),
],
child: const App(),
);
// UI
final pp = ref.scope(ProductProvider.fallback);
final list = ref.watch(pp);
autoDispose / Lifecycle
- Auto-dispose: When
autoDispose: trueand watcher=0, dispose afterautoDisposeDelay
(sincereaddoesn't create a subscription, it also delays disposal even with read-only usage) ref.onDispose(cb): Called when the provider is recalculated/disposedref.keepAlive(): Get a handle (KeepAliveLink) to prevent disposal even when unsubscribed (release withclose())
final tickProvider = Provider<int>((ref) {
var count = 0;
final timer = Timer.periodic(const Duration(seconds: 1), (_) {
count++;
ref.state = count; // State update for synchronous Provider
});
ref.onDispose(() => timer.cancel());
return count;
}, autoDispose: true, autoDisposeDelay: const Duration(milliseconds: 500));
Streams: When using
ref.emit(stream), the previous subscription is strictly cancelled on recalculation.
If you create your ownStreamController, don't forgetref.onDispose(() => controller.close()).
Flutter API
ProviderScope
- An Inherited widget that wraps the root.
- If you use external injection with
ProviderScope(container: …), you need to explicitly calldispose()on the caller side. - If you don't pass
container, it's generated internally and automatically released on Scopedispose.
Consumer (Builder version)
Consumer(
builder: (context, ref) {
final count = ref.watch(counterProvider);
return Text('$count');
},
);
Internally, "redraw on change" is always debounced to post-frame, so
"setState during build" exceptions do not occur.
ConsumerWidget (Riverpod compatible)
class Header extends ConsumerWidget {
const Header({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}
ConsumerWidgetdirectly extendsWidget. A dedicated Element injectsref.
ConsumerStatefulWidget / ConsumerState (Riverpod compatible)
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
bool flag = false;
@override
Widget build(BuildContext context) {
final value = ref.watch(tickProvider); // ← ref is a property of State
return SwitchListTile(
value: flag,
onChanged: (v) => setState(() => flag = v),
title: Text('tick = $value'),
);
}
}
Writing Tests
Pure Dart
void main() {
test('basic', () {
final c = ProviderContainer();
final p = Provider<int>((ref) => 1);
expect(c.read(p), 1);
// invalidate / refresh
c.invalidate(p);
expect(c.refresh(p), 1);
c.dispose();
});
}
ProviderContainer.listen(p, onData)does not notify the initial value. It only receives update events.
If you need the initial value, specifyfireImmediately: true, and for testing consecutive events,
add async waits withawait pump/Future.microtask, etc.
family-like override
class ProductById extends Provider<Product> {
ProductById(this.id) : super.args((id,));
final String id;
@override
Product build(Ref<Product> ref) {
final repo = ref.watch(productRepoProvider);
return repo.fetch(id);
}
}
Provider<Product> productByIdProvider(String id) => ProductById(id);
test('override per arg', () {
final c = ProviderContainer(
overrides: [
productByIdProvider('a')
.overrideWithValue(const Product(id: 'a', name: 'stub')),
],
);
expect(c.read(productByIdProvider('a')).name, 'stub');
c.dispose();
});
Flutter Widgets
- If using an externally injected container, explicitly call
container.dispose()at the end of the test (to avoid pending timers). - Containers generated internally by
ProviderScopeare automaticallydispose()d.
testWidgets('autoDispose demo', (tester) async {
final container = ProviderContainer();
await tester.pumpWidget(
ProviderScope(container: container, child: MaterialApp(home: MyPage())),
);
// ...test...
await tester.pumpWidget(const SizedBox()); // Unmount
container.dispose(); // Explicit disposal
await tester.pump();
});
Migration Notes (from Riverpod)
Provider((ref) => ...)/AsyncProvider((ref) async => ...)(function positional argument)- Mutations:
ref.read(provider.notifier).method()→
ref.invoke(provider.method(...)) - family: Instead of
*.family, use subclass +args - ConsumerWidget: You can write
build(BuildContext, WidgetRef)as-is
(directWidgetinheritance + dedicated Element)
Troubleshooting
"setState during build" Exception
Consumer/ConsumerWidget/ConsumerStatein mini_riverpod debounce update notifications to the next frame.- If you call
ProviderContainer.listenyourself, don't callsetStatedirectly during build;
useWidgetsBinding.instance.addPostFrameCallbackto post-frame it.
Mutation Exceptions
- Execution cancelled with
restart:CancelledMutation - Execution dropped with
dropLatest:DroppedMutation
→ In UI, it's common to swallow these or lightly notify.
ProviderObserver Notification Scope
providerDidFailonly notifies for AsyncProvider errors and mutation failures.
SynchronousProviderbuild exceptions are thrown directly to the caller, so
they are not notified toproviderDidFail.
Strict Stream Disposal
ref.emit(stream)cancels the previous subscription on recalculation/invalidation/disposal.
Don't forgetref.onDispose(controller.close)for your ownStreamController.
autoDispose Gotchas
- When unsubscribed, it disposes after
autoDisposeDelay.
Sincereaddoesn't create a subscription, the disposal timer runs even with read-only usage.
Be aware that recalculation runs on remount.
Useref.keepAlive()if you want to keep it.
Small Sample (Comprehensive)
// API
final apiProvider = Provider<Api>((ref) => Api());
class UserProvider extends AsyncProvider<User?> {
UserProvider() : super.args(null);
@override
FutureOr<User?> build(Ref<AsyncValue<User?>> ref) async {
return ref.watch(apiProvider).me();
}
late final renameMut = mutation<void>(#rename);
Call<void, AsyncValue<User?>> rename(String name) => mutate(
renameMut,
(ref) async {
final cur = ref.watch(this).valueOrNull;
ref.state = AsyncData((cur ?? const User()).copyWith(name: name), isRefreshing: true);
await ref.watch(apiProvider).rename(name);
ref.state = AsyncData(await ref.watch(apiProvider).me());
},
concurrency: Concurrency.restart,
);
}
final userProvider = UserProvider();
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final av = ref.watch(userProvider);
return switch (av) {
AsyncLoading() => const Text('Loading...'),
AsyncError(:final error) => Text('Error: $error'),
AsyncData(:final value) => Column(
children: [
Text('Hello, ${value?.name ?? '(null)'}'),
ElevatedButton(
onPressed: () => ref.invoke(userProvider.rename('Alice')),
child: const Text('Rename to Alice'),
),
],
),
};
}
}
Appendix: Main API Reference
Core
Provider<T>((ref) => T, {autoDispose, autoDisposeDelay})AsyncProvider<T>((ref) async => T, {autoDispose, autoDisposeDelay})Provider.args(args)/AsyncProvider.args(args)(for subclasses)ref.watch(provider)/ref.listen(provider, listener)/ref.scope(scopeToken)/ref.invoke(call)ref.invalidate(provider, {keepPrevious})/ref.refresh(provider)/ref.refreshValue(asyncProvider, {keepPrevious})ref.onDispose(cb)/ref.keepAlive()/ref.emit(stream)(inside AsyncProvider)AsyncValue<T> = AsyncLoading / AsyncData / AsyncError(hasvalueOrNull)- Mutations:
mutation<T>(#symbol)→mutate(token, (ref) async { ... }, concurrency: ...) - Concurrency control:
Concurrency.concurrent | queue | restart | dropLatest - Scope:
Scope<T>.required(name)→overrideWithValue(value)
Flutter
ProviderScope(child: ...)(autodispose()ifcontainer:is not passed)Consumer(builder: (context, ref) { ... })ConsumerWidget.build(BuildContext, WidgetRef)ConsumerStatefulWidget+ConsumerState(refproperty injection)