Every Flutter state management article starts the same way: install this package, wrap your app in this provider, learn this pattern, follow this convention.
But Flutter ships with reactive state primitives built into the framework. No packages. No code generation. No provider trees. No learning curve beyond the framework itself.
For a surprising number of use cases, ValueNotifier and ListenableBuilder are all you need. And knowing when they're enough — and when they're not — is a sign of architectural maturity, not laziness.
The Built-In Primitives
ValueNotifier
ValueNotifier<T> is a class that holds a single value and notifies listeners when it changes.
final counter = ValueNotifier<int>(0);
// Listen to changes
counter.addListener(() {
print('Counter changed to: ${counter.value}');
});
// Update
counter.value = 5; // prints "Counter changed to: 5"That's it. No events. No streams. No providers. A box that holds a value and tells you when it changes.
ListenableBuilder
ListenableBuilder is a widget (added in Flutter 3.10) that rebuilds when a Listenable (like ValueNotifier) changes. Flutter also has ValueListenableBuilder<T>, which passes the value directly to the builder — but ListenableBuilder is more flexible since it works with any Listenable, not just ValueListenable.
class CounterPage extends StatefulWidget {
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
final _counter = ValueNotifier<int>(0);
@override
void dispose() {
_counter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ListenableBuilder(
listenable: _counter,
builder: (context, child) {
return Text(
'${_counter.value}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _counter.value++,
child: const Icon(Icons.add),
),
);
}
}Only the Text widget inside the ListenableBuilder rebuilds. The Scaffold, the Center, the FloatingActionButton — none of them rebuild. This is surgical rebuild control with zero packages.
Real Patterns, Not Toy Examples
Form With Validation
class LoginForm extends StatefulWidget {
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _email = ValueNotifier<String>('');
final _password = ValueNotifier<String>('');
final _isSubmitting = ValueNotifier<bool>(false);
// Combine multiple notifiers
late final Listenable _formChanged = Listenable.merge([_email, _password]);
bool get _isValid =>
_email.value.contains('@') && _password.value.length >= 8;
Future<void> _submit() async {
if (!_isValid || _isSubmitting.value) return;
_isSubmitting.value = true;
try {
await authService.login(_email.value, _password.value);
} finally {
_isSubmitting.value = false;
}
}
@override
void dispose() {
_email.dispose();
_password.dispose();
_isSubmitting.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: (v) => _email.value = v,
decoration: const InputDecoration(labelText: 'Email'),
),
TextField(
onChanged: (v) => _password.value = v,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
ListenableBuilder(
listenable: _formChanged,
builder: (context, _) {
return ListenableBuilder(
listenable: _isSubmitting,
builder: (context, _) {
return ElevatedButton(
onPressed: _isValid && !_isSubmitting.value ? _submit : null,
child: _isSubmitting.value
? const CircularProgressIndicator()
: const Text('Login'),
);
},
);
},
),
],
);
}
}No FormBloc. No loginProvider. No LoginController extends GetxController. Just Flutter.
Toggle / Expansion State
class ExpandableSection extends StatefulWidget {
final String title;
final Widget content;
const ExpandableSection({required this.title, required this.content});
@override
State<ExpandableSection> createState() => _ExpandableSectionState();
}
class _ExpandableSectionState extends State<ExpandableSection> {
final _isExpanded = ValueNotifier<bool>(false);
@override
void dispose() {
_isExpanded.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: () => _isExpanded.value = !_isExpanded.value,
child: ListenableBuilder(
listenable: _isExpanded,
builder: (context, _) {
return Row(
children: [
Text(widget.title),
Icon(_isExpanded.value
? Icons.expand_less
: Icons.expand_more),
],
);
},
),
),
ListenableBuilder(
listenable: _isExpanded,
builder: (context, _) {
if (!_isExpanded.value) return const SizedBox.shrink();
return widget.content;
},
),
],
);
}
}This is a pattern you might use ten times in an app. Each instance is self-contained. No global state. No provider registration. No BLoC events for opening and closing a section.
Search Filter With Debounce
class SearchableList extends StatefulWidget {
@override
State<SearchableList> createState() => _SearchableListState();
}
class _SearchableListState extends State<SearchableList> {
final _query = ValueNotifier<String>('');
final _results = ValueNotifier<List<Item>>([]);
Timer? _debounce;
@override
void initState() {
super.initState();
_query.addListener(_onQueryChanged);
}
void _onQueryChanged() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
_performSearch(_query.value);
});
}
Future<void> _performSearch(String query) async {
final items = await searchService.search(query);
_results.value = items;
}
@override
void dispose() {
_debounce?.cancel();
_query.removeListener(_onQueryChanged);
_query.dispose();
_results.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: (v) => _query.value = v,
decoration: const InputDecoration(hintText: 'Search...'),
),
Expanded(
child: ListenableBuilder(
listenable: _results,
builder: (context, _) {
return ListView.builder(
itemCount: _results.value.length,
itemBuilder: (_, i) => ItemTile(item: _results.value[i]),
);
},
),
),
],
);
}
}Debounced search. Reactive results. Zero dependencies.
Combining Multiple ValueNotifiers
Listenable.merge lets you listen to multiple notifiers at once:
final name = ValueNotifier('');
final age = ValueNotifier(0);
final isActive = ValueNotifier(true);
final anyChanged = Listenable.merge([name, age, isActive]);
ListenableBuilder(
listenable: anyChanged,
builder: (context, _) {
return Text('${name.value}, ${age.value}, ${isActive.value}');
},
)This rebuilds when any of the three change. For independent rebuild control, use separate ListenableBuilder widgets for each notifier.
When ValueNotifier Is Enough
Local widget state that doesn't need to be shared across the widget tree. If the state lives and dies with the widget, ValueNotifier is the right tool.
Simple reactive UI — toggles, form validation, counters, expand/collapse, visibility, loading states.
Performance-sensitive rebuilds where you want explicit control over exactly which widget subtree rebuilds, without the overhead of a state management framework's diffing logic.
Prototyping before you know what state management the feature actually needs. Start with ValueNotifier. If it gets complex, upgrade to a package. You'll know when.
When ValueNotifier Is NOT Enough
Shared state across unrelated widgets. ValueNotifier doesn't have a built-in mechanism for making state available to distant parts of the widget tree. You'd need to pass it down manually or use an InheritedWidget — at which point you're reinventing Provider.
Complex dependency chains. If state A depends on state B which depends on state C, managing this with ValueNotifiers and manual listeners gets messy. This is exactly what Riverpod's dependency graph or computed signals solve.
Async data with loading/error states. ValueNotifier holds a value. It doesn't have built-in concepts for "loading" or "error." You'd need to create ValueNotifier<AsyncState<T>> and build the pattern yourself. Riverpod's AsyncValue handles this out of the box.
State that needs to survive widget disposal. ValueNotifier is tied to a widget's lifecycle (typically created in initState, disposed in dispose). Global or shared state that outlives individual screens needs a different home.
Audit trails and traceability. ValueNotifier doesn't record what changed the value or when. For features where you need that history — BLoC.
The Decision
Here's the rule: start with the simplest tool that works.
If the state is local to one widget or one screen, ValueNotifier is the simplest tool. If you find yourself threading ValueNotifiers through constructors to share them, you've outgrown ValueNotifier — use Provider, Riverpod, or BLoC.
Three lines of ValueNotifier code are better than a premature abstraction. A senior developer who uses ValueNotifier where it's appropriate and BLoC where it's needed is making better architectural decisions than one who uses BLoC everywhere "for consistency."
The goal isn't tool loyalty. It's matching complexity to the problem.
---
Next in this series: [GetX: What It Gets Right, What It Gets Wrong, and When to Use It Anyway](/blog/getx-honest-review) — the most popular and most controversial state management tool, examined honestly.