State Management Fundamentals

ValueNotifier & ValueListenableBuilder

45 min Lesson 6 of 14

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';
Important: 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),
        ),
      ],
    );
  },
)
Tip: Always use the 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),
      ),
    );
  }
}
Warning: For complex objects (lists, maps, custom classes), 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

  • ValueNotifier holds a single value and notifies listeners automatically when it changes.
  • ValueListenableBuilder rebuilds only the widgets inside its builder, providing scoped rebuilds.
  • The child parameter preserves static widgets across rebuilds for performance gains.
  • Multiple ValueNotifier instances can be used independently or nested.
  • ValueNotifier is more performant than setState for isolating rebuilds to specific parts of the UI.
  • Always dispose every ValueNotifier in the owning widget’s dispose method.
  • For complex objects, always assign a new instance rather than mutating in place.