One-shot operations as first-class state, with declarative listeners.
More in . The action handles the operation; the bus handles the consequences (analytics, profile sync, notifications). The action stays tiny and the side effects stay testable.
4. Consuming from the UI
The widget has two jobs: render the current state and react to transitions.
@override
Widget build(BuildContext context) {
final isLoading = ref.watch(loginActionProvider).isLoading;
ref.listenAction(
loginActionProvider,
onSuccess: (_) => context.go(HomeRoute.routePath),
onError: (error, _) => context.showErrorToast(error),
);
return CustomButton(
isLoading: isLoading,
onPressed: () => ref.read(loginActionProvider.notifier).run(
email: _email,
password: _password,
),
child: Text(context.l10n.loginSubmit),
);
}
That's the whole API. watch for rendering, listenAction for side effects, read(...).run(...) to fire it.
5. The Listener Extension
extension ActionNotifierX on WidgetRef {
void listenAction<T>(
ProviderListenable<ActionState<T>> provider, {
void Function(T data)? onSuccess,
void Function(Exception error, StackTrace stack)? onError,
void Function()? onLoading,
}) {
listen<ActionState<T>>(provider, (_, next) {
switch (next) {
case ActionLoading(): onLoading?.call();
case ActionSuccess(:final data): onSuccess?.call(data);
case ActionError(:final error, :final stackTrace):
onError?.call(error, stackTrace);
case ActionIdle(): break;
}
});
}
}
Each callback is optional — most screens only need onError. The exhaustive switch over the sealed union means adding a new state at the type level forces every call site to handle it.
Why This Matters
Idle is real. Forms render correctly before the user has done anything. No fakeloading: false, data: nullchecks.
No try/catch in widgets. Errors flow through the state and surface inonError. Domain errors stay in the domain layer.
Free double-tap protection.if (state.isLoading) returnin the mixin means every action is debounced by construction.
Tree-shaped lifecycle. Side effects are declared at the top ofbuildnext to the state they depend on, not buried inside callbacks.
Testable. Override the provider withloginActionProvider.overrideWith(...)and pump states (idle → loading → error) to assert UI behavior.
Trade-offs to Consider
Not for queries. Actions are one-shot, user-initiated, and write-flavored. For server data that should auto-fetch and cache, useFutureProvider/AsyncNotifier— that's whatAsyncValueis for.
One operation per provider. AUserActionwithlogin(),logout(), anddeleteAccount()methods sharing one state is a smell — they'd stomp on each other. Make them separate providers.
Result lifetime is short. WithautoDispose, the success value lives only as long as the screen. If you need to persist the result (e.g. the JWT from login), publish it to a domain event or write it to a repository inside the action — don't read it from the action's state later.
SOCIAL SHARE CARD GENERATOR