ValueNotifier & ValueListenableBuilder
What is ValueNotifier?
ValueNotifier is a lightweight ChangeNotifier that holds a single value and automatically calls notifyListeners() whenever that value changes. Instead of creating a full ChangeNotifier subclass with custom methods, you can use ValueNotifier for simple, single-value state like a counter, a boolean toggle, or a search query string.
Creating a ValueNotifier
// A simple integer notifier
final counter = ValueNotifier<int>(0);
// A boolean toggle
final isVisible = ValueNotifier<bool>(true);
// A string value
final searchQuery = ValueNotifier<String>('');
// A nullable value
final selectedId = ValueNotifier<int?>(null);
// Update the value — automatically notifies listeners
counter.value = 10;
isVisible.value = false;
searchQuery.value = 'flutter';
ValueNotifier only fires notifications when the new value is different from the current value (checked via the != operator). Setting the same value again does not trigger a rebuild.
ValueListenableBuilder Widget
ValueListenableBuilder is a widget that listens to a ValueNotifier and rebuilds only itself whenever the value changes. This is the key advantage: unlike setState, which rebuilds the entire StatefulWidget, ValueListenableBuilder provides scoped, surgical rebuilds.
Basic ValueListenableBuilder Usage
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@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(
appBar: AppBar(title: const Text('Counter')),
body: Center(
// Only this builder rebuilds when counter changes
child: ValueListenableBuilder<int>(
valueListenable: _counter,
builder: (context, value, child) {
return Text(
'Count: \$value',
style: const TextStyle(fontSize: 32),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _counter.value++,
child: const Icon(Icons.add),
),
);
}
}
Scoped Rebuilds: Only the Listening Widget Rebuilds
The child parameter in ValueListenableBuilder is a powerful optimization. Widgets passed as child are built once and reused across rebuilds, saving unnecessary work.
Using the child Parameter for Optimization
ValueListenableBuilder<int>(
valueListenable: _counter,
// The child is built once and passed to the builder
child: const Text(
'Current count:',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
builder: (context, value, child) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// child is the pre-built Text widget above
child!,
Text(
'\$value',
style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
),
],
);
},
)
child parameter for static parts of the UI within a ValueListenableBuilder. This prevents Flutter from rebuilding widgets that never change, improving performance.
Multiple ValueNotifiers
In real applications you often need more than one piece of state. You can nest multiple ValueListenableBuilder widgets or use them side by side. Each one listens to its own notifier independently.
Multiple Independent ValueNotifiers
class FilterPage extends StatefulWidget {
const FilterPage({super.key});
@override
State<FilterPage> createState() => _FilterPageState();
}
class _FilterPageState extends State<FilterPage> {
final _searchQuery = ValueNotifier<String>('');
final _showOnlyActive = ValueNotifier<bool>(false);
final _sortAscending = ValueNotifier<bool>(true);
@override
void dispose() {
_searchQuery.dispose();
_showOnlyActive.dispose();
_sortAscending.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Search field — updates _searchQuery
TextField(
onChanged: (val) => _searchQuery.value = val,
decoration: const InputDecoration(hintText: 'Search...'),
),
// Toggle active filter
ValueListenableBuilder<bool>(
valueListenable: _showOnlyActive,
builder: (context, showActive, _) {
return SwitchListTile(
title: const Text('Show only active'),
value: showActive,
onChanged: (val) => _showOnlyActive.value = val,
);
},
),
// Sort direction toggle
ValueListenableBuilder<bool>(
valueListenable: _sortAscending,
builder: (context, ascending, _) {
return TextButton.icon(
icon: Icon(ascending ? Icons.arrow_upward : Icons.arrow_downward),
label: Text(ascending ? 'Ascending' : 'Descending'),
onPressed: () => _sortAscending.value = !ascending,
);
},
),
// Results list combining all filters
Expanded(
child: ValueListenableBuilder<String>(
valueListenable: _searchQuery,
builder: (context, query, _) {
return ValueListenableBuilder<bool>(
valueListenable: _showOnlyActive,
builder: (context, activeOnly, _) {
return ValueListenableBuilder<bool>(
valueListenable: _sortAscending,
builder: (context, ascending, _) {
return _buildList(query, activeOnly, ascending);
},
);
},
);
},
),
),
],
);
}
Widget _buildList(String query, bool activeOnly, bool ascending) {
// Filter and sort logic here
return const ListView(); // Placeholder
}
}
ValueNotifier vs setState Performance
With setState, the entire build method re-executes. Every widget returned by build is re-evaluated (though Flutter’s reconciliation skips unchanged subtrees). With ValueListenableBuilder, only the builder callback re-runs.
setState vs ValueNotifier Comparison
// APPROACH 1: setState — entire build() re-runs
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) {
// ALL of this rebuilds on every setState call
return Scaffold(
appBar: AppBar(title: const Text('setState Example')),
body: Column(
children: [
const ExpensiveHeader(), // Rebuilds unnecessarily!
Text('Count: \$_count'),
const ExpensiveFooter(), // Rebuilds unnecessarily!
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _count++),
child: const Icon(Icons.add),
),
);
}
}
// APPROACH 2: ValueNotifier — only the count text rebuilds
class ValueNotifierPage extends StatefulWidget {
const ValueNotifierPage({super.key});
@override
State<ValueNotifierPage> createState() => _ValueNotifierPageState();
}
class _ValueNotifierPageState extends State<ValueNotifierPage> {
final _count = ValueNotifier<int>(0);
@override
void dispose() {
_count.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// This build() runs only ONCE
return Scaffold(
appBar: AppBar(title: const Text('ValueNotifier Example')),
body: Column(
children: [
const ExpensiveHeader(), // Never rebuilds
ValueListenableBuilder<int>(
valueListenable: _count,
builder: (context, value, _) {
return Text('Count: \$value'); // Only this rebuilds
},
),
const ExpensiveFooter(), // Never rebuilds
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _count.value++,
child: const Icon(Icons.add),
),
);
}
}
ValueNotifier uses the != operator to detect changes. If you mutate a list in place and reassign it, the reference is the same and no notification fires. Always create a new list instance: notifier.value = [...notifier.value, newItem].
Disposing ValueNotifiers
Every ValueNotifier must be disposed when the owning widget is removed from the tree. This releases internal listener subscriptions and prevents memory leaks.
Proper Disposal Pattern
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
final _name = ValueNotifier<String>('');
final _age = ValueNotifier<int>(0);
final _isActive = ValueNotifier<bool>(false);
@override
void dispose() {
_name.dispose();
_age.dispose();
_isActive.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Widget tree using the notifiers...
return const Placeholder();
}
}
Practical Example: Search Filter
A debounced search filter is a perfect fit for ValueNotifier. The text field updates the notifier, and the results list rebuilds only when the query changes.
Search Filter with ValueNotifier
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _query = ValueNotifier<String>('');
final _items = ['Flutter', 'Dart', 'React', 'Swift', 'Kotlin'];
@override
void dispose() {
_query.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
onChanged: (val) => _query.value = val.toLowerCase(),
decoration: const InputDecoration(
hintText: 'Search...',
border: InputBorder.none,
),
),
),
body: ValueListenableBuilder<String>(
valueListenable: _query,
builder: (context, query, _) {
final filtered = _items
.where((item) => item.toLowerCase().contains(query))
.toList();
return ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
return ListTile(title: Text(filtered[index]));
},
);
},
),
);
}
}
Practical Example: Form Field Tracking
Track whether a form is valid in real time by combining multiple ValueNotifier instances.
Form Validation with ValueNotifiers
class SignUpForm extends StatefulWidget {
const SignUpForm({super.key});
@override
State<SignUpForm> createState() => _SignUpFormState();
}
class _SignUpFormState extends State<SignUpForm> {
final _email = ValueNotifier<String>('');
final _password = ValueNotifier<String>('');
final _isFormValid = ValueNotifier<bool>(false);
@override
void initState() {
super.initState();
// Listen to both fields and update form validity
_email.addListener(_validateForm);
_password.addListener(_validateForm);
}
void _validateForm() {
_isFormValid.value =
_email.value.contains('@') && _password.value.length >= 8;
}
@override
void dispose() {
_email.removeListener(_validateForm);
_password.removeListener(_validateForm);
_email.dispose();
_password.dispose();
_isFormValid.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: (val) => _email.value = val,
decoration: const InputDecoration(labelText: 'Email'),
),
TextField(
onChanged: (val) => _password.value = val,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 16),
ValueListenableBuilder<bool>(
valueListenable: _isFormValid,
builder: (context, isValid, _) {
return ElevatedButton(
onPressed: isValid ? () => _submit() : null,
child: const Text('Sign Up'),
);
},
),
],
);
}
void _submit() {
// Handle form submission
}
}
Summary
ValueNotifierholds a single value and notifies listeners automatically when it changes.ValueListenableBuilderrebuilds only the widgets inside its builder, providing scoped rebuilds.- The
childparameter preserves static widgets across rebuilds for performance gains. - Multiple
ValueNotifierinstances can be used independently or nested. ValueNotifieris more performant thansetStatefor isolating rebuilds to specific parts of the UI.- Always dispose every
ValueNotifierin the owning widget’sdisposemethod. - For complex objects, always assign a new instance rather than mutating in place.