Skip to main content

Submit state

A mutation - save, delete, send, log in - has a lifecycle the UI has to track: it is idle, then in progress, then it succeeds or fails. Hand-rolled, that is a loading flag and a try/catch wrapped around every action:

// The shape useSubmitState replaces: a hand-rolled loading flag plus a try/catch.
class ManualSave extends HookWidget {
const ManualSave({super.key, required this.service, required this.draft});

final ItemService service;
final String draft;


Widget build(BuildContext context) {
final inProgressState = useState(false);

Future<void> save() async {
inProgressState.value = true;
try {
await service.save(draft);
} finally {
inProgressState.value = false;
}
}

return ElevatedButton(
onPressed: inProgressState.value ? null : save,
child: inProgressState.value ? const CircularProgressIndicator() : const Text('Save'),
);
}
}

useSubmitState is the write counterpart to useAutoComputedState. It owns that lifecycle. You wrap the work in run; it tracks inProgress for you and rethrows on failure so the error reaches the app-level pipeline:

class SaveButton extends HookWidget {
const SaveButton({super.key, required this.service, required this.draft});

final ItemService service;
final String draft;


Widget build(BuildContext context) {
final saveState = useSubmitState();

// No try/catch: a thrown error rethrows out of run and reaches the app-level
// error pipeline, which also gets a Retryable handle for free.
return ElevatedButton(
// inProgress does not block duplicates by itself - gate the tap explicitly.
onPressed: saveState.inProgress ? null : () => saveState.run(() => service.save(draft)),
child: saveState.inProgress ? const CircularProgressIndicator() : const Text('Save'),
);
}
}

Two things are deliberate here. There is no try/catch - an unhandled error is meant to crash to the error boundary, not be swallowed (see Error handling). And run does not block a duplicate call; it only counts in-flight runs, so the inProgress guard on the button is doing real work.

run does not block duplicates

inProgress is true while any run is in flight (the hook tracks concurrent runs internally), not a lock. Calling run again while one is in flight starts a second, parallel run. There are three ways to gate that, depending on the trigger:

// 1. A manual inProgress check (shown in the basic example above)
onPressed: saveState.inProgress ? null : () => saveState.run(save),

// 2. runSimple with skipIfInProgress - silently skips a re-entrant call
onTrigger: () => saveState.runSimple<void, Never>(submit: save, skipIfInProgress: true),

// 3. useSubmitButtonState - the button's onTap guards itself (below)

Wiring a button

Most submits are behind a button that needs to disable and show a spinner while running. toButtonState projects a submit state into a ButtonState carrying loading: inProgress; a design-system button reads that and ignores taps while loading. useSubmitButtonState goes one step further - it bundles the submit state and a tap guard into one call:

class SubmitButtonScreen extends HookWidget {
const SubmitButtonScreen({super.key, required this.service, required this.draft});

final ItemService service;
final String draft;


Widget build(BuildContext context) {
// useSubmitButtonState bundles a submit state and a button whose onTap already
// returns early while inProgress - no manual guard needed.
final saveButton = useSubmitButtonState(
() => service.save(draft),
enabled: draft.isNotEmpty,
);

return ElevatedButton(
onPressed: saveButton.onTapIfEnabled,
child: saveButton.loading ? const CircularProgressIndicator() : const Text('Save'),
);
}
}

saveButton.onTapIfEnabled is null when enabled is false, so the button greys out; and the bundled onTap returns early while inProgress, so a double-tap can't start two saves. For the button-shaped API in full, see useSubmitButtonState.

Forms: runSimple

run is the primitive; runSimple layers the full submit flow on top as ordered callbacks. The whole login form - validate, submit, handle a known backend error, navigate on success - is one declarative call:

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();

bool validate() {
email.errorMessage = isValidEmail(email.value) ? null : (context) => 'Invalid email';
password.errorMessage = password.value.length >= 8 ? null : (context) => 'Min 8 characters';
return email.errorMessage == null && password.errorMessage == null;
}

void login() => submitState.runSimple<String, LoginException>(
// shouldSubmit is the one validation gate: return false to abort, and the
// fields already carry their error messages.
shouldSubmit: validate,
submit: () => auth.logIn(email: email.value, password: password.value),
afterSubmit: onLoggedIn,
// Known backend error -> typed; null means "unknown", which rethrows and crashes.
mapError: (e) => e is LoginException ? e : null,
afterKnownError: (e) => password.errorMessage = switch (e.code) {
LoginErrorCode.invalidCredentials => (context) => 'Incorrect email or password',
LoginErrorCode.tooManyAttempts => (context) => 'Too many attempts - try later',
},
);

// toButtonState feeds inProgress into the button as `loading`; a design-system
// button ignores taps while loading.
final buttonState = submitState.toButtonState(
enabled: email.value.isNotEmpty && password.value.isNotEmpty,
onTap: login,
);

return Column(
children: [
TextField(onChanged: (it) => email.value = it),
TextField(onChanged: (it) => password.value = it, obscureText: true),
ElevatedButton(
onPressed: buttonState.enabled && !buttonState.loading ? buttonState.onTap : null,
child: buttonState.loading ? const CircularProgressIndicator() : const Text('Log in'),
),
],
);
}
}

The callbacks fire in this order:

  1. shouldSubmit - the pre-check. Return false to abort (and afterShouldNotSubmit runs). This is the validation gate: run every field, return whether all passed. No separate isFormValid() helper.
  2. beforeSubmit -> submit -> afterSubmit(result) on the happy path.
  3. On a thrown error: afterError (always), then mapError converts the raw error to a typed E (null means "unknown"). If it returns non-null, afterKnownError(E) handles it; otherwise the error rethrows and crashes.

That last split is the point. Errors you have UX for - a known backend code, shown as a field message - are caught by mapError. Everything else - network down, a contract change, a null deref - rethrows to the app-level pipeline. Add mapError/afterKnownError only when you have specific error UX; a mapError that returns the error for every case silently buries real bugs.

caution

shouldSubmit is the form's validation gate, and the field error messages it sets stay visible after the abort. The field's errorMessage setter takes a ValidatorResult? (a String Function(BuildContext)?), so the message resolves at render time and the state hook stays free of BuildContext. Fields also expose a .validate(validator) that runs a validator and sets errorMessage in one step:

shouldSubmit: () => [
email.validate((it) => isValidEmail(it) ? null : (context) => 'Invalid email'),
password.validate((it) => it.length >= 8 ? null : (context) => 'Min 8 characters'),
].every((it) => it),

One submit state per flow, not per button

Use one useSubmitState per independent flow, not one per action. Mutually exclusive actions on the same entity - archive vs. delete a row, the host actions in a game - share a single submit state; group them behind one run that switches on an action:

enum RowAction { archive, delete }

class GroupedActions extends HookWidget {
const GroupedActions({super.key, required this.service, required this.id});

final ItemService service;
final String id;


Widget build(BuildContext context) {
// One submit state for mutually exclusive actions on the same entity - not one
// per button.
final actionState = useSubmitState();

void onAction(RowAction action) => actionState.run(() async {
switch (action) {
case RowAction.archive:
await service.save(id);
case RowAction.delete:
await service.delete(id);
}
});

return Row(
children: [
TextButton(
onPressed: actionState.inProgress ? null : () => onAction(RowAction.archive),
child: const Text('Archive'),
),
TextButton(
onPressed: actionState.inProgress ? null : () => onAction(RowAction.delete),
child: const Text('Delete'),
),
],
);
}
}

Reach for a separate submit state only when two operations can genuinely run in parallel (the user can vote while the host advances the round) or need different error handling. A submit state per button means five inProgress flags where one would do.

caution

useSubmitState is for one-shot, user-triggered writes. It is the wrong tool for a managed side effect - presence registration, a heartbeat, a wake lock, a countdown. Those are not user actions; there is no button, no spinner, no per-call error UX. Model them with useEffect and a cleanup so the teardown is guaranteed on unmount.

See also