Flutter architecture with Screen / State / View
A StatefulWidget mixes three concerns in one class: the data a screen shows, the logic that produces it, and the widgets that render it. As the screen grows, they tangle - business logic ends up inside build, UI assumptions leak into the logic, and the whole thing becomes hard to read and impossible to unit-test without a widget tree.
The Screen / State / View pattern is the answer the rest of this architecture rests on. Every screen is split into three parts, plus a thin coordinator that wires them together:
- State - a plain, immutable data class holding everything the screen displays and every action the user can trigger.
- Hook - a function that builds the State: all logic, async work, services, and derived values live here.
- View - a
StatelessWidgetthat renders the State and calls its actions. No logic. - Screen (Coordinator) - a
HookWidgetthat readsBuildContext, builds navigation callbacks, calls the hook once, and returns the View.
Logic never bleeds into the UI, and the UI never bleeds into the logic.
State
The State is a simple immutable class. It contains:
- Data - every value needed to render the screen (the current count, a loaded user, a loading flag).
- Actions - every action the user can perform, as
void Function()fields the View calls. - Constructor - taking all data and actions as required named parameters.
- Derived data - optionally, values computed from the other fields (whether a button should be enabled).
class CounterScreenState {
// Data - everything the View needs to render
final int count;
// Actions - what the user can do; the View calls these
final void Function() onIncrementPressed;
const CounterScreenState({
required this.count,
required this.onIncrementPressed,
});
// Derived data - computed from the fields above
bool get isAtLimit => count >= 42;
}
Best practices
-
Name the class
<Name>State(e.g.CounterScreenState) where<Name>is the screen's name. This convention makes the three parts trivial to find. -
Keep Data and Actions visually separated. State may also hold complex objects that bundle both, such as
MutableFieldStatefor text fields - those can go in either section. -
Actions can take parameters (
void Function(int index) onItemPressed), but should not return values. A value the View needs belongs in a Data field, not in an action's return. This includes returning aFutureto signal async work - expose progress as aboolfield driven byuseSubmitStateinstead. -
Some fields may be nullable, but every field is
requiredin the constructor. The constructor is called in exactly one place - at the end of the hook - sorequiredguarantees nothing is left uninitialized. -
The State needs only a constructor: no
operator==, nohashCode, nocopyWith. A code generator like Freezed is tempting but unnecessary, and it works against automated refactors. Keep the class hand-written. -
Hold no cross-screen global state as a field. Re-exporting a whole global (
final AuthState authState;) forces the entire View subtree to rebuild whenever any unrelated field of that global changes, defeating the granular reactivity ofuseProvided. Project the specific values the View needs instead (final bool isLoggedIn;).
Hook
The hook builds the State and reacts to its actions. It takes input values (screen arguments) and callbacks for talking to the outside world (navigation), and it may call any number of other hooks to do its work.
CounterScreenState useCounterScreenState({
required CounterScreenArgs args,
required void Function() navigateToSuccess,
}) {
final countState = useState(args.initialValue);
void onIncrementPressed() {
countState.value++;
if (countState.value == 42) navigateToSuccess();
}
return CounterScreenState(
count: countState.value,
onIncrementPressed: onIncrementPressed,
);
}
Best practices
-
Name the hook
use<Name>State(e.g.useCounterScreenState). -
Define inner functions (like
onIncrementPressedabove) inside the hook to handle user interactions. They close over the hook's state, so they can encapsulate complex logic while still reaching every value. -
Services and global state are pulled in here with
useInjectedanduseProvided, at the top of the hook. Group those lookups together so the hook's dependencies read at a glance. -
Navigation arrives as callback parameters, never as a
BuildContextor an injected router. See Navigation is a callback below.
View
The View renders the UI from the current State and calls its actions on user input. It is usually a StatelessWidget taking the State as its only parameter.
class CounterScreenView extends StatelessWidget {
final CounterScreenState state;
const CounterScreenView({required this.state});
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: Text("Count: ${state.count}")),
floatingActionButton: FloatingActionButton(
onPressed: state.isAtLimit ? null : state.onIncrementPressed,
child: const Icon(Icons.add),
),
);
}
}
Best practices
-
Name the View
<Name>View(e.g.CounterScreenView). -
The View receives
final <Name>State stateand nothing else. It calls no hooks - nouseProvided, nouseInjected. Everything it needs is already onstate. -
Split a large View into smaller widgets or private
_buildXxxmethods. When extracting child widgets, pass the whole State object rather than individual fields - this cuts the refactoring needed when the child's data requirements change. -
Never build a business callback as a closure in the View. A closure that touches a service or
Navigatorcouples the UI to logic; that callback belongs on the State class as a field the hook builds.// DON'T - business logic in the ViewReplyBox(onSendTapped: (text) {if (!state.isLoggedIn) {Navigator.of(context).pushNamed('/login'); // <- logic leaking into the Viewreturn;}state.onReplyWith(text);},);// DO - the hook composed onSendReply; the View just passes it throughReplyBox(onSendTapped: state.onSendReply);
Screen (Coordinator)
The Screen is the entry point and pure wiring. It reads BuildContext, builds the navigation and dialog callbacks the hook needs, calls the hook exactly once, and returns the View.
class CounterScreen extends HookWidget {
final CounterScreenArgs args;
const CounterScreen({required this.args});
Widget build(BuildContext context) {
final state = useCounterScreenState(
args: args,
navigateToSuccess: () => Navigator.of(context).pushNamed('/success'),
);
return CounterScreenView(state: state);
}
}
The Screen's build may read from BuildContext (Navigator.of(context), MediaQuery.of(context), route arguments, XDialog.show(context)), call the one hook with callbacks built inline, and return XScreenView(state: state). It must not call useInjected, useProvided, useState, useEffect, or any other hook itself - those belong in the state hook. A well-formed Screen is typically 30-80 lines.
HookCoordinator is a shorthand that binds a hook and a View, letting the Screen stay a plain StatelessWidget:
class CounterScreenCoordinator extends StatelessWidget {
final CounterScreenArgs args;
const CounterScreenCoordinator({required this.args});
Widget build(BuildContext context) => HookCoordinator(
use: () => useCounterScreenState(
args: args,
navigateToSuccess: () => Navigator.of(context).pushNamed('/success'),
),
builder: (state) => CounterScreenView(state: state),
);
}
HookCoordinator is itself a HookWidget, so the hook runs in a real hook context.
Navigation is a callback
Navigation is built in the Screen from BuildContext and passed to the hook as callback parameters. The hook stores them as fields on the State; the View calls them. The hook never navigates by itself.
// DON'T - injecting navigation into the state hook
HabitDetailsScreenState useHabitDetailsScreenState({required Habit habit}) {
final router = useInjected<AppRouter>(); // <- never
final navigatorKey = useProvided<NavigatorKey>(); // <- never
// ...
}
// DON'T - passing BuildContext into the state hook
HabitDetailsScreenState useHabitDetailsScreenState({
required Habit habit,
required BuildContext context, // <- never
}) {
void onEditPressed() => EditHabitDialog.show(context); // <- the hook is doing dialog wiring
// ...
}
Passing BuildContext looks cheaper than three callbacks, but it lets the hook call Navigator.of(context) and XDialog.show(context) internally - exactly the wiring the Screen owns - and makes the hook untestable without a widget tree. The callbacks are the contract the hook is meant to expose. The Screen closes over BuildContext and builds them.
The lightweight tier
The triple is the default, not a tax on every widget. A trivial, self-contained component - a confirmation dialog, a one-action info sheet - may be a single HookWidget that calls useInjected / useSubmitState directly in build, as long as it stays around 30 lines of logic and has no navigation beyond popping itself. There, the widget is the Screen layer, so there is no state hook for it to smuggle UI into.
Promote it to the full triple as soon as any of these appear: it opens other dialogs or pushes routes, it has more than one async flow or any derived state, it has form fields with validation, or its build exceeds roughly 30 lines of logic. Promotion is mechanical - the widget becomes the Screen, logic moves into useXState, UI moves into the View.
File organization
Keep the three parts in a dedicated directory, split between State and View:
screens/counter
|- counter_screen.dart - Screen (Coordinator) + route arguments
|- state
| |- counter_screen_state.dart - State class and hook
|- view
| |- counter_screen_view.dart - View
Keep the State and hook in the same file to minimize context switching; split them only if either grows unwieldy. Extracted sub-hooks go under state/, extracted sub-widgets under view/.
See also
- Local state - the State / Hook / View parts in more depth
- Global state - sharing state across screens with
useProvided - Dependency injection & services -
useInjectedinside the state hook - useProvided - reading global state in the hook
- HookCoordinator - the Screen-as-
StatelessWidgetshorthand - Unit testing - testing a state hook without a widget tree