Skip to main content

Flutter architecture with hooks

The hard part of Flutter architecture is not drawing widgets. It is deciding where state, business logic, async work, navigation, shared app data, and tests belong. utopia_hooks answers that with one presentation-layer shape: Screen / State / View.

The pattern is simple enough for small screens, but strict enough for large apps and coding agents:

  1. Screen wires the outside world: route arguments, BuildContext, navigation, dialogs, sheets.
  2. State hook owns logic: local state, global state reads, services, async hooks, derived values, callbacks.
  3. State class is the complete data/action contract the UI can render.
  4. View renders only the State and calls State actions.

The target shape

screens/order_details
|- order_details_screen.dart
|- state/order_details_screen_state.dart
|- view/order_details_screen_view.dart

order_details_screen.dart is the coordinator. It calls exactly one hook, for example useOrderDetailsScreenState(...), and returns OrderDetailsScreenView(state: state).

state/order_details_screen_state.dart contains the State class and hook. This is where useState, useProvided, useAutoComputedState, useSubmitState, useMemoized, and service calls live.

view/order_details_screen_view.dart is a StatelessWidget. It receives state and nothing else.

The full rule set lives in Screen / State / View.

Why this is different from widget-first Flutter

StatefulWidget gives you local mutable state, but it does not answer shared state, async loading, pagination, form fields, or unit-testable presentation logic. Those concerns usually get patched together with separate mechanisms.

With hooks, the same model covers the screen:

ConcernWhere it goesHook / API
Local UI stateState hookuseState
Derived valuesState hookuseMemoized
Side effectsState hookuseEffect
Global app stateGlobal hook + State hook readHookProviderContainerWidget, useProvided
Async readState hookuseAutoComputedState
Submit / mutationState hookuseSubmitState
PaginationState hook + View wrapperusePaginatedComputedState, PaginatedComputedStateWrapper
Form field stateState hook + View wrapperuseFieldState, TextEditingControllerWrapper
Unit testTestSimpleHookContext

Global state stays hook-shaped

Shared app state is not a separate architecture. It is still a State class plus a hook, registered once at the app root and consumed with useProvided<T>().

That matters for app architecture because global states can depend on other global states in a visible order, and screens consume only the fields they need. Start with Global state, then use App bootstrap when initialization order matters.

Async work uses state machines, not flags

The architecture avoids hand-rolled isLoading / error / value triplets. Use:

This is the part that makes the architecture friendly to both humans and coding agents: there are fewer plausible-but-wrong ways to wire loading, retry, pagination, and form submit behavior.

A state hook should not hold BuildContext or reach for a router directly. The Screen owns BuildContext, builds typed callbacks, and passes them into the hook. The View then calls the callback exposed on State.

That keeps state hooks unit-testable and prevents navigation code from bleeding into UI widgets. See Navigation.

Test the logic without the widget tree

Because a state hook is just a function over a hook context, it can be tested with SimpleHookContext:

final context = SimpleHookContext(useCounterScreenState);

expect(context().count, 0);
context().onIncrementPressed();
expect(context().count, 1);

The full testing guide is Testing Flutter state without widget tests.