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+Statesplit, nocreateState. State is declared next to where it is read. - Lifecycle in one place.
initStateanddisposecollapse into a singleuseEffectwhose teardown cannot drift from its setup.didUpdateWidgetbecomes akeysarray. - Logic you can test. A
StatefulWidget's logic lives inbuildand aStateclass, 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
Statelogic 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.
- utopia_hooks
- Vanilla Flutter
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),
),
);
}
}
class CounterScreen extends StatefulWidget {
const CounterScreen({super.key});
State<CounterScreen> createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
int _count = 0; // <- the mutable field
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: Text('Count: $_count')),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _count++), // <- mutate, then 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.
- utopia_hooks
- Vanilla Flutter
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}'));
}
}
class ClockScreen extends StatefulWidget {
const ClockScreen({super.key, required this.service});
final TickerService service;
State<ClockScreen> createState() => _ClockScreenState();
}
class _ClockScreenState extends State<ClockScreen> {
int _tick = 0;
late final StreamSubscription<int> _subscription;
void initState() {
super.initState();
_subscription = widget.service.ticks.listen(
(value) => setState(() => _tick = value),
);
}
void dispose() {
_subscription.cancel(); // <- easy to forget; leaks if you do
super.dispose();
}
Widget build(BuildContext context) => Center(child: Text('Tick: $_tick'));
}
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.
- utopia_hooks
- Vanilla Flutter
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))]);
}
}
class ResultsScreen extends StatefulWidget {
const ResultsScreen({super.key, required this.service, required this.query});
final SearchService service;
final String query;
State<ResultsScreen> createState() => _ResultsScreenState();
}
class _ResultsScreenState extends State<ResultsScreen> {
List<String>? _results;
void initState() {
super.initState();
_load();
}
void didUpdateWidget(ResultsScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) _load(); // <- manual comparison
}
Future<void> _load() async {
setState(() => _results = null);
final data = await widget.service.search(widget.query);
if (mounted) setState(() => _results = data);
}
Widget build(BuildContext context) {
final data = _results;
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
| StatefulWidget | utopia_hooks | Notes |
|---|---|---|
extends StatefulWidget + State class | extends HookWidget | One class; hooks are called in build |
Mutable State field | useState(initial) | Read .value, write .value = |
setState(() => field = x) | state.value = x | The write itself triggers the rebuild |
initState | useEffect(() { ... }, []) (no keys) | Runs once on first build |
dispose | the function returned from useEffect | Setup and teardown in one hook |
initState + dispose for a stream | useStreamSubscription | Subscribes and auto-cancels |
didUpdateWidget comparison | useEffect/hook keys | Re-runs only when a key changes |
late final controller field | useScrollController, useAnimationController, useFocusNode | Created once, disposed automatically |
with SingleTickerProviderStateMixin | useSingleTickerProvider | Vsync without the mixin |
WidgetsBindingObserver lifecycle | useAppLifecycleState | Foreground/background as a value |
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
HookWidgetshould land - Basics - the three core hooks and the hook rules
- useState - the
setStatereplacement, in depth - useEffect -
initState/disposeas one hook - Flutter hooks - the controllers and lifecycle helpers that replace mixins and fields