Skip to content
Sign in

Flutter State Management: setState vs Provider vs Riverpod (with One Example App)

Build the same quiz-score screen three ways in Flutter, with setState, Provider and Riverpod, and learn exactly when each approach is the right choice.

CodeOrbit Learn TeamPublished 5 min read

"State" is any data that can change while your app runs: the text in a field, the current question number, whether the user is logged in. When state changes, the UI must rebuild to show it. Flutter gives you many ways to manage this, and beginners often pick a heavy tool too early, or stay with the simplest one for too long.

In this tutorial we build one small feature three times: a quiz screen that shows the current score and a button to add a point. Seeing the same feature in each style makes the trade-offs obvious.

1. setState: state that belongs to one widget

setState is built into Flutter. You keep the data inside a StatefulWidget and call setState when it changes.

Dart
class ScoreScreen extends StatefulWidget {
  const ScoreScreen({super.key});

  @override
  State<ScoreScreen> createState() => _ScoreScreenState();
}

class _ScoreScreenState extends State<ScoreScreen> {
  int score = 0;

  void addPoint() => setState(() => score++);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Quiz')),
      body: Center(child: Text('Score: $score', style: const TextStyle(fontSize: 32))),
      floatingActionButton: FloatingActionButton(
        onPressed: addPoint,
        child: const Icon(Icons.add),
      ),
    );
  }
}

What happens: setState marks this widget as dirty, Flutter calls build again, and the new score appears.

Use it when the state is local: a checkbox, an animation toggle, a form field, the selected tab inside one screen.

It becomes a problem when another screen needs the same score. You end up passing values and callbacks down through many constructors ("prop drilling"), and every widget in between rebuilds.

2. Provider: share state down the widget tree

The provider package lets you put an object high in the tree and read it from any widget below. The object extends ChangeNotifier and calls notifyListeners() when it changes.

Dart
// pubspec.yaml: provider: ^6.1.0

class QuizModel extends ChangeNotifier {
  int _score = 0;
  int get score => _score;

  void addPoint() {
    _score++;
    notifyListeners();
  }

  void reset() {
    _score = 0;
    notifyListeners();
  }
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => QuizModel(),
      child: const MyApp(),
    ),
  );
}

Any widget can now watch or read the model:

Dart
class ScoreText extends StatelessWidget {
  const ScoreText({super.key});

  @override
  Widget build(BuildContext context) {
    final score = context.watch<QuizModel>().score;   // rebuilds when score changes
    return Text('Score: $score', style: const TextStyle(fontSize: 32));
  }
}

class AddButton extends StatelessWidget {
  const AddButton({super.key});

  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () => context.read<QuizModel>().addPoint(),   // no rebuild needed here
      child: const Icon(Icons.add),
    );
  }
}

Use it when several screens share data (cart, logged-in user, quiz progress) and your app is small to medium.

Watch out for: reading a provider that isn't above the widget in the tree throws ProviderNotFoundException. Place providers above MaterialApp when every screen needs them.

3. Riverpod: providers without the BuildContext limits

Riverpod was written by the same author as Provider to fix its weak spots. Providers are global declarations (not widgets), they can depend on each other, and mistakes are caught at compile time.

Dart
// pubspec.yaml: flutter_riverpod: ^2.5.0

class ScoreNotifier extends Notifier<int> {
  @override
  int build() => 0;            // initial state

  void addPoint() => state++;
  void reset() => state = 0;
}

final scoreProvider = NotifierProvider<ScoreNotifier, int>(ScoreNotifier.new);

void main() => runApp(const ProviderScope(child: MyApp()));

class ScoreScreen extends ConsumerWidget {
  const ScoreScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final score = ref.watch(scoreProvider);
    return Scaffold(
      body: Center(child: Text('Score: $score', style: const TextStyle(fontSize: 32))),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(scoreProvider.notifier).addPoint(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Riverpod really shines with async data. Loading questions from an API becomes one provider that handles loading, error and data states:

Dart
final questionsProvider = FutureProvider<List<Question>>((ref) async {
  final api = ref.watch(apiProvider);
  return api.fetchQuestions();
});

// In a ConsumerWidget:
final questions = ref.watch(questionsProvider);
return questions.when(
  loading: () => const CircularProgressIndicator(),
  error: (e, _) => Text('Could not load questions'),
  data: (list) => ListView(children: [for (final q in list) Text(q.text)]),
);

Use it when the app is growing, you load data from APIs, you want easy testing (providers can be overridden in tests), or several pieces of state depend on each other.

Side-by-side comparison


setState

Provider

Riverpod

Setup

None

One package, wrap app

One package, ProviderScope

Scope

One widget

Subtree below provider

Whole app, declared globally

Async loading

Manual flags

Manual flags

Built in (FutureProvider, AsyncValue)

Testing

Widget tests only

Possible

Easy (override providers)

Best for

Local UI state

Small/medium apps

Medium/large apps

A simple rule to choose

  1. Start with setState for anything only one widget cares about.
  2. When two or more screens need the same data, move that data into Provider or Riverpod.
  3. If you are starting a new app that will grow, or you fetch a lot of API data, choose Riverpod from day one.

Many real apps use both: Riverpod for app-wide data (user, settings, test progress) and setState for tiny local things (is this card expanded?).

Common mistakes

  • Calling setState after dispose. If an async call finishes after the user left the screen, check if (!mounted) return; before setState.
  • Putting everything in one giant model. Split by feature: AuthModel, QuizModel, SettingsModel.
  • Using watch inside callbacks. In Provider and Riverpod, use read in onPressed, watch in build.

Practice task

Add a "Reset" button and a "Best score" that survives between quizzes. Implement it first with setState, then move it to Riverpod and notice how the reset button can now live on a completely different screen.

Tags:#Flutter#Dart#State Management

Frequently asked questions

Is setState bad practice?

No. It is the right tool for state that belongs to one widget. It only becomes painful when many widgets need the same data.

Should beginners learn Provider or Riverpod first?

Learn setState first, then Riverpod. Provider is still common in older codebases, so it helps to recognise it, but Riverpod is the better default for new projects.

What about Bloc or GetX?

Bloc is a solid choice for large teams that like strict event/state patterns. The ideas in this tutorial (local vs shared state, separating logic from UI) apply to every library.