Skip to main content

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 - true while at least one run is in flight. run increments a counter on entry and decrements it on completion.
  • run - the low-level primitive. Runs block, tracking it in inProgress. By default (isRetryable: true) a thrown error is wrapped with Retryable so it can be retried upstream; the error is then rethrown.
  • runSimple - an opinionated wrapper over run covering the common submit flow. The callbacks fire in order: shouldSubmit (if it returns false, afterShouldNotSubmit runs and the flow aborts) -> beforeSubmit -> submit -> afterSubmit; on a thrown error, afterError -> mapError (raw error to typed E) -> afterKnownError if mapError returned 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 - runSimple expresses 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, useSubmitButtonState bundles a useSubmitState with a tap guard; or call toButtonState(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

  • run does 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 saves
    onPressed: () => submitState.run(save);

    // DO - gate on inProgress
    onPressed: submitState.inProgress ? null : () => submitState.run(save);

    // DO - runSimple skips silently while one is running
    onTrigger: () => submitState.runSimple<void, Never>(submit: save, skipIfInProgress: true);

    // DO - useSubmitButtonState already guards the tap
    final buttonState = useSubmitButtonState(save);
  • Let errors crash by default. Add mapError / afterKnownError only 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. A mapError that 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 one run that switches on an action enum. Use separate states only for operations that can genuinely run in parallel or need different error handling.

  • inProgress reflects a counter, so it stays true until the last in-flight run finishes. If you fire several parallel runs on one submit state, inProgress is true for the whole batch.

See also