Skip to main content

Error handling

The async hooks share one rule: let errors crash by default. A useSubmitState.run rethrows; a failed useAutoComputedState lands its error in value and propagates the auto-load failure too. None of them swallow. That sounds reckless until you see where the crash lands - an app-root catcher that reports every uncaught error and surfaces it as a retryable dialog. The job of error handling is to build that catcher once, then get out of the way.

The anti-pattern is catching because "something has to":

// DON'T - swallowed: no report, no dialog, no retry
Future<void> save() => submitState.run(() async {
try {
await itemService.save(draft);
} catch (e) {
print(e);
}
});
// DO - no catch; the app-root pipeline reports it and offers a retry
Future<void> save() => submitState.run(() => itemService.save(draft));

Where errors land

ErrorWhere it goes
Uncaught error in submitState.runRethrown; the (usually unawaited) future reaches the zone handler -> report + dialog. run attaches a Retryable by default.
Failed useComputedState / useAutoComputedStateCaptured in value as failed for the View to render. The auto-triggered refresh is fire-and-forget, so the failure also reaches the zone.
Expected error (known backend code)Handled locally via runSimple's mapError / afterKnownError - e.g. a field message. Never reaches the pipeline.
Flutter framework errorFlutterError.onError -> report; non-silent ones also surface to the dialog.

The expected/unexpected split is the whole model. Errors you have UX for are handled where they happen (see runSimple in Submit state); everything else is left to crash into the pipeline.

The app-root catcher

Two catchers cover everything the let-it-crash rule throws: runZonedGuarded for uncaught async errors, and FlutterError.onError for framework errors. A broadcast stream carries each error out to the UI layer. This is about twenty lines of your own code, no extra packages:

typedef AppError = ({Object error});

void runWithGlobalErrorHandling(
AppReporter reporter,
void Function(Stream<AppError> uiErrors) block,
) {
final controller = StreamController<AppError>.broadcast();

void handle(Object error, StackTrace? stack) {
reporter.report(error, stack);
controller.add((error: error)); // Retryable stays attached to the error itself.
}

// Framework errors (build / layout / paint). Silent ones are reported, not surfaced.
FlutterError.onError = (details) {
FlutterError.presentError(details);
if (!details.silent) handle(details.exception, details.stack);
};

// Uncaught async errors - the let-it-crash path (unawaited submit futures, etc.).
runZonedGuarded(() => block(controller.stream), handle);
}
danger

WidgetsFlutterBinding.ensureInitialized() and runApp must be called inside the zone - inside the block. Initialize them outside and framework callbacks bind to the outer zone, so some errors slip past the handler entirely.

Wire it at the app root: run the app through the catcher, then subscribe to the error stream below the provider container and push a dialog for each error.

class MyApp extends HookWidget {
const MyApp({super.key, required this.uiErrors});

final Stream<AppError> uiErrors;

static void run() {
runWithGlobalErrorHandling(appReporter, (uiErrors) {
// Must be called INSIDE the zone, or some framework errors bypass the handler.
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp(uiErrors: uiErrors));
});
}


Widget build(BuildContext context) {
final navigatorKey = useMemoized(GlobalKey<NavigatorState>.new);

useStreamSubscription<AppError>(
uiErrors,
(error) async => _handleUiError(context, error, navigatorKey.currentState!),
// drop = ignore new errors while one dialog is showing - no stacked dialogs.
strategy: StreamSubscriptionStrategy.drop,
);

return MaterialApp(navigatorKey: navigatorKey, home: const SizedBox.shrink());
}

Future<void> _handleUiError(BuildContext context, AppError error, NavigatorState navigator) async {
if (error.error is AssertionError) return; // debug-time noise - already reported
await navigator.push(DialogRoute<void>(context: context, builder: (_) => AppErrorDialog(error: error)));
}
}

The StreamSubscriptionStrategy.drop matters: the handler awaits the dialog's dismissal, and with the default parallel strategy a burst of errors would stack one dialog per error. drop collapses the burst into a single dialog. Pushing through the root navigator key is the sanctioned global-navigator access at the app shell - screens never do this.

Retryable powers the Retry button

Retryable (from utopia_utils, re-exported by utopia_hooks) attaches a retry closure to an error object itself, via an Expando. The original exception keeps propagating unchanged, and any catcher can recover the closure with Retryable.tryGet(error). The dialog reads it straight off the error - the Retry button only appears when the error carries a handle:

class AppErrorDialog extends StatelessWidget {
const AppErrorDialog({super.key, required this.error});

final AppError error;


Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Error'),
content: Text(kDebugMode ? error.error.toString() : 'Something has gone wrong.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
// Retryable.tryGet returns null when the error carries no retry handle, so
// the Retry button only appears when there is something to retry.
if (Retryable.tryGet(error.error)?.retry case final retry?)
TextButton(
onPressed: () {
Navigator.of(context).pop();
retry();
},
child: const Text('Retry'),
),
],
);
}
}

Who attaches a handle, by default:

APIisRetryable defaultWhat retry() does
submitState.run / runSimpletruere-runs the block
useComputedStatefalse (opt in)calls refresh() again
useAutoComputedStatefalse (opt in)calls refresh() again

So submissions are retryable for free; computed states opt in with isRetryable: true. If you want an inline retry on a failed read - an error placeholder in the list instead of the global dialog - pull the closure off the value yourself:

// A failed computed state can offer an inline retry instead of the global dialog.
void Function()? inlineRetry(MutableComputedState<Object> state) => state.value.maybeWhen(
failed: (e) => Retryable.tryGet(e)?.retry,
orElse: () => null,
);
caution

retry() re-runs the original computation without re-checking preconditions. For useAutoComputedState, shouldCompute may have flipped to false by the time the user taps Retry - the retry is not re-gated. Opt in to isRetryable deliberately, not reflexively.

When a catch is right

Letting errors crash does not mean never catching - it means catching narrowly. When you do need a try/catch (an effect launching a Future, a Timer callback), bind the exception and narrow it to the type you actually mean to handle:

// DON'T - a bare catch-all buries parse errors, null derefs, contract changes
try {
final verified = await authService.isEmailVerified();
emailVerifiedState.setIfMounted(verified);
} catch (_) {
// "network blips" - the comment that hides real bugs
}

// DO - narrow to the one case you tolerate; report it; let the rest crash
try {
final verified = await authService.isEmailVerified();
emailVerifiedState.setIfMounted(verified);
} on NetworkException catch (e) {
appReporter.warning('email verification check failed', e: e);
}

The AppReporter you pass to the pipeline doubles as your breadcrumb channel - use info / warning for the handled-but-noteworthy cases where you deliberately do not crash.

info

utopia_arch ships a ready-made version of this pipeline - the same reporter, zone-plus-framework catcher, and broadcast stream of UI errors carrying Retryable handles. The hand-rolled catcher here is a drop-in equivalent for apps that don't depend on it.

See also

  • Submit state - runSimple's mapError / afterKnownError, the local side of the expected/unexpected split
  • Computed state - how a failed read surfaces in value and renders via the wrappers
  • Global state - the provider container the pipeline wiring sits above
  • useStreamSubscription - the drop strategy that prevents stacked dialogs