miniriverpod 0.0.1
miniriverpod: ^0.0.1 copied to clipboard
A lightweight reimplementation of the Riverpod experience. No code generation required for family/mutations/concurrency control.
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)