useSubmitButtonState
Wraps useSubmitState into a ready-to-bind ButtonState for a single action. Its onTap runs the action and ignores taps while one is already in flight, so the double-tap guard is built in.
class SaveButton extends HookWidget {
const SaveButton({super.key, required this.service});
final DocumentService service;
Widget build(BuildContext context) {
// One hook owns the action + its in-flight state; onTap ignores taps while running.
final buttonState = useSubmitButtonState(service.save);
return ElevatedButton(
onPressed: buttonState.onTapIfEnabled, // <- null while disabled, so the button greys out
child: buttonState.loading // <- true while the action runs
? const CircularProgressIndicator()
: const Text('Save'),
);
}
}
Signature
ButtonState useSubmitButtonState(Future<void> Function() action, {bool enabled = true});
class ButtonState {
final bool loading, enabled;
final void Function() onTap;
}
// extensions:
ButtonState SubmitState.toButtonState({required void Function() onTap, bool enabled = true});
void Function()? ButtonState.onTapIfEnabled; // onTap when enabled, else null
action- the work to run on tap.enabled(trueby default) - whether the button is interactive. Whenfalse,onTapIfEnabledisnull.loading- mirrors the underlying submit state'sinProgress.onTap- runsactionthrough the submit state, returning early if a run is already in progress.onTapIfEnabled-onTapwhenenabled, otherwisenull- exactly whatonPressedwants so the button greys out when disabled.
Use cases
-
A button whose only job is to fire one async action and show a spinner while it runs - a save, a confirm, a send.
-
Disabling the button until a form is valid, while still showing the loading spinner once it is tapped:
class SubmitButton extends HookWidget {const SubmitButton({super.key, required this.service, required this.formIsValid});final DocumentService service;final bool formIsValid;Widget build(BuildContext context) {// enabled gates the button independently of the loading state.final buttonState = useSubmitButtonState(service.save, enabled: formIsValid);return ElevatedButton(onPressed: buttonState.onTapIfEnabled,child: buttonState.loading ? const CircularProgressIndicator() : const Text('Submit'),);}}
When the action needs validation, typed errors, or post-submit navigation, drive a useSubmitState with runSimple and convert it with toButtonState(onTap:) - useSubmitButtonState is the shortcut for the case where run(action) is all you need.
Caveats
-
onTapswallows the returned future (unawaited). Errors thrown byactionstill propagate through the submit state'srun(and are madeRetryableby default), so they surface in the error pipeline - but you can'tawaitthe tap. If you need to chain work after the action, useuseSubmitStatedirectly. -
The tap guard checks
inProgress, which is shared with the underlying submit state. Re-entrancy is blocked for this button only; it does not coordinate with other submit states on the screen. -
enabled: falseblocks taps viaonTapIfEnabledbeingnull, butloadingis independent - a disabled button can still be showing a spinner if the action is mid-flight. Render from both fields. -
This hook pairs with design-system buttons that accept a
ButtonStatedirectly. Where such a wrapper exists, prefer it over readingloading/onTapIfEnabledby hand in the View.
See also
- useSubmitState - the underlying lifecycle, plus
toButtonStatefor the validation/typed-error case - useFieldState - the
enabledflag often derives from field validity