Flutter Intermediate Lesson 38 of 135 4 min read
Flutter State Management Without Packages: All 9 Built-in Tools
A simple map of Flutter's 9 built-in state management tools, from setState and ValueNotifier to InheritedModel and AnimatedBuilder, with runnable examples.
On this page
Flutter has nine built-in tools for state. They look like a lot, but they fit together in three simple groups:
- Store a value and rebuild:
setState - Hold a value outside the widget and listen to it:
ValueNotifier,ChangeNotifier, and the builders that listen:ValueListenableBuilder,ListenableBuilder,AnimatedBuilder - Share a value with many widgets down the tree:
InheritedWidget,InheritedNotifier,InheritedModel
The nine tools at a glance
| No. | Tool | What it does | Use it when | Lesson |
|---|---|---|---|---|
| 1 | setState() | Changes a field in a State and rebuilds that widget | State belongs to one widget | setState |
| 2 | ValueNotifier | Holds one value and tells listeners when it changes | A single value: a counter, a toggle | ValueNotifier |
| 3 | ValueListenableBuilder | Rebuilds a small part of the UI when a ValueNotifier changes | Showing a ValueNotifier | ValueNotifier |
| 4 | ChangeNotifier | A class with several values; you call notifyListeners() | A model: cart, form, settings | ChangeNotifier |
| 5 | InheritedWidget | Makes data available to every widget below it | Sharing data without passing it down | InheritedWidget |
| 6 | InheritedNotifier | An InheritedWidget that rebuilds dependants when a notifier changes | Sharing a ChangeNotifier | InheritedNotifier |
| 7 | InheritedModel | An InheritedWidget where widgets depend on only part of the data | Large shared models | InheritedModel |
| 8 | ListenableBuilder | Rebuilds when any Listenable changes | Showing a ChangeNotifier | ListenableBuilder |
| 9 | AnimatedBuilder | Same as ListenableBuilder, traditionally used with animations | Animations, or any Listenable | AnimatedBuilder |
The same counter, three ways
1. setState keeps the number inside the widget:
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: SetStatePage()));
class SetStatePage extends StatefulWidget {
const SetStatePage({super.key});
@override
State<SetStatePage> createState() => _SetStatePageState();
}
class _SetStatePageState extends State<SetStatePage> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('setState')),
body: Center(child: Text('$_count', style: const TextStyle(fontSize: 48))),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _count++),
child: const Icon(Icons.add),
),
);
}
}
2. ValueNotifier + ValueListenableBuilder keeps the number outside the widget, so the page can be a StatelessWidget and only the Text rebuilds:
import 'package:flutter/material.dart';
final counter = ValueNotifier<int>(0);
void main() => runApp(const MaterialApp(home: NotifierPage()));
class NotifierPage extends StatelessWidget {
const NotifierPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ValueNotifier')),
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) =>
Text('$value', style: const TextStyle(fontSize: 48)),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => counter.value++,
child: const Icon(Icons.add),
),
);
}
}
3. ChangeNotifier + InheritedNotifier shares the counter with every screen below it:
import 'package:flutter/material.dart';
class Counter extends ChangeNotifier {
int value = 0;
void increment() {
value++;
notifyListeners();
}
}
class CounterScope extends InheritedNotifier<Counter> {
const CounterScope({super.key, required Counter counter, required super.child})
: super(notifier: counter);
static Counter of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<CounterScope>()!.notifier!;
}
void main() {
runApp(CounterScope(
counter: Counter(),
child: const MaterialApp(home: SharedPage()),
));
}
class SharedPage extends StatelessWidget {
const SharedPage({super.key});
@override
Widget build(BuildContext context) {
final counter = CounterScope.of(context);
return Scaffold(
appBar: AppBar(title: const Text('InheritedNotifier')),
body: Center(
child: Text('${counter.value}', style: const TextStyle(fontSize: 48)),
),
floatingActionButton: FloatingActionButton(
onPressed: counter.increment,
child: const Icon(Icons.add),
),
);
}
}
Look at how the code grows. That growth is exactly what packages like Provider and Riverpod remove.
Which built-in tool should I use?
Is the state used by only one widget?
├── Yes → setState
└── No → Is it a single value?
├── Yes → ValueNotifier + ValueListenableBuilder
└── No → ChangeNotifier + ListenableBuilder
Do many screens need it?
└── Yes → share it with InheritedNotifier (or Provider)
Key words to remember
- Ephemeral state: belongs to one widget (a text field, an animation, the selected tab).
setStateis perfect. - App state: used in many places (the logged-in user, the cart). Keep it outside widgets.
- Listenable: anything that can tell you “I changed”.
ValueNotifier,ChangeNotifier,AnimationControllerandStream-based classes are all listenables. - Rebuild: Flutter calls
buildagain. Rebuilding is cheap, but rebuilding less keeps big apps smooth.
Try it yourself
Run all three counters. Then add a second button that resets the number to zero in each version. Notice which version needed the fewest changes.