HomeDocumentationState Management in Flutter
State Management in Flutter
9

You Don't Need a Package: ValueNotifier + ListenableBuilder for Simple State

Flutter ValueNotifier + ListenableBuilder: State Management Without Packages

April 4, 2026

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.

dart
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.

dart
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

dart
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

dart
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

dart
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:

dart
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.

Related Topics

flutter valuenotifier tutorialflutter listenablebuilder exampleflutter state management without packageflutter built-in state managementvaluenotifier vs providerflutter simple state managementflutter valuelistenablebuilderlistenable merge flutterflutter local state no packageflutter setState alternative

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.