Skip to main content

Why utopia_hooks

A Flutter screen has to do four things: hold local state, read shared app state, run async work, and stay testable. The framework gives you one tool for the first job - StatefulWidget - and leaves the rest to a patchwork of other libraries. The result is a presentation layer assembled from parts that don't share a model.

StatefulWidget is where it starts. State lives in a second class reached through createState; you read a field, then call setState to write it; lifecycle splits across initState and dispose; reacting to changed inputs means a hand-written didUpdateWidget. None of that logic can be unit-tested without pumping a widget tree, so it tends to stay in build, where it tangles with the UI.

Shared state is a separate decision - a provider package, a stream, a singleton - with its own way of being read and its own lifecycle. Async loading is a third: FutureBuilder, a manual mounted check, a bool isLoading you wire up by hand. Each concern is a different shape, and gluing them together is the work.

utopia_hooks is one model for all four. A hook is a composable unit of state and logic, called from build. The same mechanism that holds a counter reads global state, drives an async fetch, and runs under a plain unit test with no widget tree. Local state, shared state, async, and testable logic stop being four problems with four libraries and become one.

Same feature, two models​

Here is the smallest version of the difference: a counter. Flip the toggle to compare.

class CounterScreen extends HookWidget {
const CounterScreen({super.key});


Widget build(BuildContext context) {
final count = useState(0);

return Scaffold(
body: Center(child: Text('Count: ${count.value}')),
floatingActionButton: FloatingActionButton(
onPressed: () => count.value++, // <- the write schedules the rebuild
child: const Icon(Icons.add),
),
);
}
}

One class instead of two, no createState, and the state declaration sits next to where it is used. The payoff grows with the screen: initState/dispose collapse into a single useEffect whose teardown can't drift from its setup, and didUpdateWidget becomes a keys array that re-runs work only when an input changes. The From StatefulWidget guide walks the full mapping.

What you get​

Composability. Hooks call other hooks. useFuture is useState plus an effect; a paginated list is a few hooks stacked together. You build a screen's logic by combining small pieces, and you factor a repeated combination into a custom hook the same way you factor a function - no base class, no mixin.

One model for local and global state. Shared state is a hook too. Register it once at the app root, then read it anywhere with useProvided; it rebuilds only the hooks that read it. Local state and app-wide state are written and consumed the same way, so moving a value from one scope to the other is a small change, not a rewrite onto a different library. See Global state.

Testable logic, no widget tree. Because a hook is just a function over a context, it runs in a unit test without Flutter. Wrap it in a SimpleHookContext and assert on the value it produces:

final context = SimpleHookContext(useCounterState);

expect(context().value, 0);
context().onIncrement();
expect(context().value, 1);

The logic that would otherwise be trapped in build becomes a plain function you can drive directly - and dependencies can be faked through the same context.

These three add up to the architecture the rest of the docs build on: the Screen / State / View pattern, where a screen is a State class, a hook that builds it, and a View that renders it - logic and UI cleanly apart, every part of it testable.

See also​

  • Getting Started - install the package and run the first hook
  • Basics - the three core hooks and the rules for calling them
  • Screen / State / View - the architecture these hooks are built for
  • Comparisons - how utopia_hooks compares to StatefulWidget, BLoC, Riverpod, flutter_hooks, and React


Crafted for you by UtopiaSoftware 👾