Skip to main content

utopia_hooks vs StatefulWidget

A StatefulWidget keeps mutable state in a companion State object and rebuilds when you call setState. HookWidget keeps the same state in hooks called from build, and rebuilds when a hook's value changes. The migration is mechanical: every State field becomes a useState, and every initState/dispose pair becomes a useEffect.

Why utopia_hooks

StatefulWidget is the only state tool Flutter ships, and it shows. utopia_hooks keeps what works and removes the friction:

  • One class, not two. No StatefulWidget + State split, no createState. State is declared next to where it is read.
  • Lifecycle in one place. initState and dispose collapse into a single useEffect whose teardown cannot drift from its setup. didUpdateWidget becomes a keys array.
  • Logic you can test. A StatefulWidget's logic lives in build and a State class, reachable only by pumping a widget tree. The same logic in a state hook runs under a plain unit test.
  • Composability. Hooks call other hooks, so a repeated setup factors into a custom hook - there is no equivalent for sharing State logic short of a mixin or a base class.

The toggle below shows the same widget written both ways. Flip it on any code block on this page to compare.

A counter: fields and setState

useState replaces a mutable field plus the setState call that writes it. Reading is .value; writing is .value =, and the write schedules the rebuild for you.

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


Widget build(BuildContext context) {
final count = useState(0); // <- replaces the `int _count` field + setState

return Scaffold(
body: Center(child: Text('Count: ${count.value}')),
floatingActionButton: FloatingActionButton(
onPressed: () => count.value++, // <- a write triggers 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.

A subscription: initState and dispose

The classic reason to reach for StatefulWidget is lifecycle: open a subscription, timer, or controller in initState, tear it down in dispose. A single useEffect owns both ends - the body is the setup, the returned function is the teardown - so the two halves can't drift apart. For a stream specifically, useStreamSubscription wraps that effect and cancels for you.

class ClockScreen extends HookWidget {
const ClockScreen({super.key, required this.service});

final TickerService service;


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

// initState + dispose collapse into one effect: subscribe, and return the
// teardown. No keys means it runs once, like initState.
useStreamSubscription(service.ticks, (value) => tick.value = value);

return Center(child: Text('Tick: ${tick.value}'));
}
}

Setup and teardown live in one place instead of two methods a screen apart. There is no late final subscription field to declare, and no way to forget the cancel.

Reacting to changed inputs: didUpdateWidget

When a widget rebuilds with new constructor arguments, StatefulWidget notifies you through didUpdateWidget, where you compare oldWidget to widget and re-run work by hand. A hook's keys do the comparison: pass the inputs you depend on, and the work re-runs only when one of them changes. Here useAutoComputedState re-fetches whenever query changes.

class ResultsScreen extends HookWidget {
const ResultsScreen({super.key, required this.service, required this.query});

final SearchService service;
final String query;


Widget build(BuildContext context) {
// The keys array replaces didUpdateWidget: the search re-runs whenever
// `query` changes, and only then.
final results = useAutoComputedState(
() => service.search(query),
keys: [query],
);

final data = results.valueOrNull;
if (data == null) return const Center(child: CircularProgressIndicator());

return ListView(children: [for (final r in data) ListTile(title: Text(r))]);
}
}

The keys: [query] array states the dependency once. There is no initState-plus-didUpdateWidget duplication, and no manual mounted guard before the final write - the hook handles disposal.

The mapping

StatefulWidgetutopia_hooksNotes
extends StatefulWidget + State classextends HookWidgetOne class; hooks are called in build
Mutable State fielduseState(initial)Read .value, write .value =
setState(() => field = x)state.value = xThe write itself triggers the rebuild
initStateuseEffect(() { ... }, []) (no keys)Runs once on first build
disposethe function returned from useEffectSetup and teardown in one hook
initState + dispose for a streamuseStreamSubscriptionSubscribes and auto-cancels
didUpdateWidget comparisonuseEffect/hook keysRe-runs only when a key changes
late final controller fielduseScrollController, useAnimationController, useFocusNodeCreated once, disposed automatically
with SingleTickerProviderStateMixinuseSingleTickerProviderVsync without the mixin
WidgetsBindingObserver lifecycleuseAppLifecycleStateForeground/background as a value
caution

Hooks follow two rules setState does not impose: call them unconditionally and in the same order on every build (no hooks inside if or loops - use useIf when you need a conditional one), and never call them from a callback like onPressed. See Basics.

Where to stop

Swapping HookWidget for StatefulWidget is the first step, not the destination. Once the logic in build grows past a few hooks - async loading, derived values, navigation - move it out of the widget and into a state hook, leaving the widget as a thin coordinator over a separate View. That split is the Screen / State / View pattern, and it is where hooks earn their keep.

See also

  • Screen / State / View - where a migrated HookWidget should land
  • Basics - the three core hooks and the hook rules
  • useState - the setState replacement, in depth
  • useEffect - initState/dispose as one hook
  • Flutter hooks - the controllers and lifecycle helpers that replace mixins and fields