useSubmitState
Manages the lifecycle of a write or mutation: idle -> in progress -> success/error. It returns a MutableSubmitState that tracks how many runs are in flight (inProgress) and wraps the work with retry support.
class SaveButton extends HookWidget {
const SaveButton({super.key, required this.onSave});
final Future<void> Function() onSave;
Widget build(BuildContext context) {
final submitState = useSubmitState();
return ElevatedButton(
// inProgress guards against a second tap while the first save runs.
onPressed: submitState.inProgress ? null : () => submitState.run(onSave),
child: submitState.inProgress ? const CircularProgressIndicator() : const Text('Save'),
);
}
}
Signature
MutableSubmitState useSubmitState();
// on MutableSubmitState:
bool get inProgress;
Future<T> run<T>(Future<T> Function() block, {bool isRetryable = true});
Future<void> runSimple<T, E>({
FutureOr<bool> Function()? shouldSubmit,
FutureOr<void> Function()? afterShouldNotSubmit,
FutureOr<void> Function()? beforeSubmit,
required Future<T> Function() submit,
FutureOr<void> Function(T)? afterSubmit,
FutureOr<E?> Function(Object)? mapError,
FutureOr<void> Function(E)? afterKnownError,
FutureOr<void> Function()? afterError,
bool isRetryable = true,
bool skipIfInProgress = false,
});
inProgress-truewhile at least one run is in flight.runincrements a counter on entry and decrements it on completion.run- the low-level primitive. Runsblock, tracking it ininProgress. By default (isRetryable: true) a thrown error is wrapped withRetryableso it can be retried upstream; the error is then rethrown.runSimple- an opinionated wrapper overruncovering the common submit flow. The callbacks fire in order:shouldSubmit(if it returnsfalse,afterShouldNotSubmitruns and the flow aborts) ->beforeSubmit->submit->afterSubmit; on a thrown error,afterError->mapError(raw error to typedE) ->afterKnownErrorifmapErrorreturned non-null, otherwise the error is rethrown.
Use cases
-
Any mutation triggered by the user: save, delete, send, create, log in.
-
A submit with validation, typed error handling, and navigation on success -
runSimpleexpresses the whole flow declaratively:class LoginForm extends HookWidget {const LoginForm({super.key, required this.auth, required this.onLoggedIn});final AuthService auth;final void Function(String userName) onLoggedIn;Widget build(BuildContext context) {final email = useFieldState();final password = useFieldState();final submitState = useSubmitState();void login() => submitState.runSimple<String, LoginException>(// Pre-check: return false to abort before anything runs.shouldSubmit: () => !submitState.inProgress,submit: () => auth.logIn(email.value, password.value),afterSubmit: onLoggedIn,// Known error -> typed; null means "unknown", which rethrows and crashes.mapError: (e) => e is LoginException ? e : null,afterKnownError: (e) => password.errorMessage = (context) => e.message,);return Column(children: [TextField(onChanged: (it) => email.value = it),TextField(onChanged: (it) => password.value = it, obscureText: true),ElevatedButton(onPressed: login, child: const Text('Log in')),],);}} -
Wiring a button's loading and disabled state. For the common button case,
useSubmitButtonStatebundles auseSubmitStatewith a tap guard; or calltoButtonState(onTap:)on an existing submit state to feed a design-system button.
For reads, use useAutoComputedState. For lifecycle side effects (presence, heartbeats, wake locks) use useEffect with a cleanup - those are not user actions and gain nothing from a submit state.
Caveats
-
rundoes not block duplicate calls - it only counts in-flight runs. Calling it again while one is running starts a second, parallel run. Guard duplicates explicitly:// DON'T - a double tap starts two savesonPressed: () => submitState.run(save);// DO - gate on inProgressonPressed: submitState.inProgress ? null : () => submitState.run(save);// DO - runSimple skips silently while one is runningonTrigger: () => submitState.runSimple<void, Never>(submit: save, skipIfInProgress: true);// DO - useSubmitButtonState already guards the tapfinal buttonState = useSubmitButtonState(save); -
Let errors crash by default. Add
mapError/afterKnownErroronly when you have specific error UX (a field error, a snackbar). Without that UX there is no value in catching - the unhandled error should reach the crash reporter. AmapErrorthat returns the error for every case silently swallows real bugs. -
Use one submit state per independent flow, not per button. Mutually exclusive actions (the user can only do one at a time) share a single
useSubmitState; group them behind onerunthat switches on an action enum. Use separate states only for operations that can genuinely run in parallel or need different error handling. -
inProgressreflects a counter, so it staystrueuntil the last in-flight run finishes. If you fire several parallel runs on one submit state,inProgressistruefor the whole batch.
See also
- useSubmitButtonState - the button-shaped wrapper with a built-in tap guard
- useAutoComputedState - the read counterpart
- useFieldState - form fields whose
validate()slots intoshouldSubmit - Common hooks - submit state in the context of the wider hook set