Updated June 2026. Tested on Flutter 3.x and Dart 3. Part of the Techalyst Flutter series.

Every app has to manage state, and Flutter does not force one way to do it. That freedom is good, but it also means a lot of people pick a heavy solution far too early, or fight setState long after they should have moved on. The honest answer is that there is a progression, and you reach for the next step only when the current one starts to hurt.

Start with setState

For state that belongs to a single widget, setState is the right tool, not a beginner crutch. A toggle, a form field, an expanded panel. If only one widget cares about the value, keep it in that widget's State. We covered the mechanics in stateless vs stateful widgets.

The limit shows up the moment a second, distant widget needs the same value. You cannot reach another widget's State, so you start passing data and callbacks down through constructors. One or two levels is fine. Five levels of passing a function down just to update a counter is the smell that you have outgrown setState.

Lifting state up

The first move is not a package, it is to move the state to the closest widget that sits above everyone who needs it, then pass it down. This is "lifting state up", and for a shallow tree it is perfectly good. The parent owns the state and hands children both the value and a way to change it.

It stops scaling when the common ancestor is far above the widgets that care, because now every widget in between has to forward props it does not use. That forwarding is the pain Provider and Riverpod exist to remove.

Provider

Provider is a friendly wrapper over Flutter's own InheritedWidget, the mechanism that already pushes data down the tree efficiently. You put your state in a ChangeNotifier, expose it once near the top, and any descendant reads it from context without anyone forwarding props.

class CounterModel extends ChangeNotifier {
  int count = 0;

  void increment() {
    count++;
    notifyListeners(); // tells listening widgets to rebuild
  }
}
// near the root
ChangeNotifierProvider(
  create: (_) => CounterModel(),
  child: const MyApp(),
)

// anywhere below
final model = context.watch<CounterModel>(); // rebuilds when it changes
Text('${model.count}');

context.read<CounterModel>().increment(); // call an action, no rebuild

watch subscribes the widget so it rebuilds when the model changes. read is for firing an action without subscribing. Only the widgets that watch a value rebuild when it changes, which keeps things efficient.

Riverpod

Riverpod is the modern evolution of the same idea, from the author of Provider. It fixes a few rough edges: you do not need a BuildContext to read state, the wiring is checked at compile time so you cannot ask for a provider that does not exist, and it is far easier to test.

final counterProvider = NotifierProvider<Counter, int>(Counter.new);

class Counter extends Notifier<int> {
  @override
  int build() => 0;

  void increment() => state++;
}
class CounterText extends ConsumerWidget {
  const CounterText({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider); // rebuilds on change
    return Text('$count');
  }
}

// to change it
ref.read(counterProvider.notifier).increment();

The shape is similar to Provider, but the provider lives as a top-level variable instead of being tied to a spot in the widget tree, which is what makes it independent of context and easy to test.

How to choose

The rule is simple. State used by one widget stays in that widget with setState. State shared by a few nearby widgets can be lifted up. State shared across the app goes into Provider or Riverpod. For a new project I would start with Riverpod, because it scales without surprises and the testing story is good. For a small app, do not reach past setState just because an article told you to. Over-engineering state is as real a problem as under-engineering it.

Wrapping up

There is no single right answer for Flutter state, there is a progression. Keep local state local with setState, lift it up when a couple of nearby widgets share it, and move to Provider or Riverpod when sharing across the app turns into prop drilling. All of them are built on the same idea of pushing data down the tree to the widgets that ask for it. Match the tool to how far the state actually travels, and you avoid both the prop-drilling mess and the over-built setup that a small app never needed.