Flutter Tutorial

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:

  1. Store a value and rebuild: setState
  2. Hold a value outside the widget and listen to it: ValueNotifier, ChangeNotifier, and the builders that listen: ValueListenableBuilder, ListenableBuilder, AnimatedBuilder
  3. Share a value with many widgets down the tree: InheritedWidget, InheritedNotifier, InheritedModel

The nine tools at a glance

No.ToolWhat it doesUse it whenLesson
1setState()Changes a field in a State and rebuilds that widgetState belongs to one widgetsetState
2ValueNotifierHolds one value and tells listeners when it changesA single value: a counter, a toggleValueNotifier
3ValueListenableBuilderRebuilds a small part of the UI when a ValueNotifier changesShowing a ValueNotifierValueNotifier
4ChangeNotifierA class with several values; you call notifyListeners()A model: cart, form, settingsChangeNotifier
5InheritedWidgetMakes data available to every widget below itSharing data without passing it downInheritedWidget
6InheritedNotifierAn InheritedWidget that rebuilds dependants when a notifier changesSharing a ChangeNotifierInheritedNotifier
7InheritedModelAn InheritedWidget where widgets depend on only part of the dataLarge shared modelsInheritedModel
8ListenableBuilderRebuilds when any Listenable changesShowing a ChangeNotifierListenableBuilder
9AnimatedBuilderSame as ListenableBuilder, traditionally used with animationsAnimations, or any ListenableAnimatedBuilder

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). setState is 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, AnimationController and Stream-based classes are all listenables.
  • Rebuild: Flutter calls build again. 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.

Practise in the playground Updated