miniriverpod 0.0.1 copy "miniriverpod: ^0.0.1" to clipboard
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 with Scope
  • Future/Stream integration: Handle both Future and Stream with a single AsyncProvider, 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 #


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 Provider constructor 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 with AsyncLoading / AsyncData / AsyncError.
A when method is not provided. Please use pattern matching (switch) or is checks.


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 by watching a MutationToken<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 executions
  • queue: Serial execution (FIFO). Subsequent executions continue even if there's an error in the middle
  • restart: Only the latest is valid. Old executions throw CancelledMutation and terminate
  • dropLatest: Immediately drop new ones while executing. Throws DroppedMutation to the caller

For any of these, you can choose to swallow or notify with try/catch on the ref.invoke(...) side.
ProviderContainer.dispose() does not stop running mutations, but
queue / restart / dropLatest that complete after disposal are treated as CancelledMutation.
concurrent completes.


invalidate / refresh #

  • ref.invalidate(provider, keepPrevious: true/false)

    • Works for both synchronous and asynchronous
  • For asynchronous, if keepPrevious: true and there is a previous value,
    it transitions to AsyncData(isRefreshing: true) (if there's no previous value, it becomes AsyncLoading).

  • 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: true and watcher=0, dispose after autoDisposeDelay
    (since read doesn't create a subscription, it also delays disposal even with read-only usage)
  • ref.onDispose(cb): Called when the provider is recalculated/disposed
  • ref.keepAlive(): Get a handle (KeepAliveLink) to prevent disposal even when unsubscribed (release with close())
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 own StreamController, don't forget ref.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 call dispose() on the caller side.
  • If you don't pass container, it's generated internally and automatically released on Scope dispose.

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');
  }
}

ConsumerWidget directly extends Widget. A dedicated Element injects ref.

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, specify fireImmediately: true, and for testing consecutive events,
    add async waits with await 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 ProviderScope are automatically dispose()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
    (direct Widget inheritance + dedicated Element)

Troubleshooting #

"setState during build" Exception #

  • Consumer/ConsumerWidget/ConsumerState in mini_riverpod debounce update notifications to the next frame.
  • If you call ProviderContainer.listen yourself, don't call setState directly during build;
    use WidgetsBinding.instance.addPostFrameCallback to 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 #

  • providerDidFail only notifies for AsyncProvider errors and mutation failures.
    Synchronous Provider build exceptions are thrown directly to the caller, so
    they are not notified to providerDidFail.

Strict Stream Disposal #

  • ref.emit(stream) cancels the previous subscription on recalculation/invalidation/disposal.
    Don't forget ref.onDispose(controller.close) for your own StreamController.

autoDispose Gotchas #

  • When unsubscribed, it disposes after autoDisposeDelay.
    Since read doesn't create a subscription, the disposal timer runs even with read-only usage.
    Be aware that recalculation runs on remount.
    Use ref.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 (has valueOrNull)
  • 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: ...) (auto dispose() if container: is not passed)
  • Consumer(builder: (context, ref) { ... })
  • ConsumerWidget.build(BuildContext, WidgetRef)
  • ConsumerStatefulWidget + ConsumerState (ref property injection)

3
likes
150
points
34
downloads

Publisher

verified publisherfinitefield.org

Weekly Downloads

A lightweight reimplementation of the Riverpod experience. No code generation required for family/mutations/concurrency control.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on miniriverpod