Skip to main content

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 (true by default) - whether the button is interactive. When false, onTapIfEnabled is null.
  • loading - mirrors the underlying submit state's inProgress.
  • onTap - runs action through the submit state, returning early if a run is already in progress.
  • onTapIfEnabled - onTap when enabled, otherwise null - exactly what onPressed wants 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

  • onTap swallows the returned future (unawaited). Errors thrown by action still propagate through the submit state's run (and are made Retryable by default), so they surface in the error pipeline - but you can't await the tap. If you need to chain work after the action, use useSubmitState directly.

  • 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: false blocks taps via onTapIfEnabled being null, but loading is 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 ButtonState directly. Where such a wrapper exists, prefer it over reading loading / onTapIfEnabled by hand in the View.

See also

  • useSubmitState - the underlying lifecycle, plus toButtonState for the validation/typed-error case
  • useFieldState - the enabled flag often derives from field validity