State ManagementJuly 20, 20268 min read

Flutter State Management: From setState to Riverpod

One of the first questions every Flutter beginner asks is: "How do I update the screen when my data changes?" In Flutter the UI is a pure function of state: UI = f(State). This guide walks you through every major approach — from a simple counter to production-scale architecture.

01Ephemeral vs App State

Before choosing a library you must distinguish two fundamentally different kinds of state:

Ephemeral (Local) State

State that lives inside a single widget — such as which tab is selected, whether a password field is hidden, or the current value of an animation. Managed with the built-in setState() call. No external package needed.

App (Shared) State

State shared across multiple screens or features — such as the authenticated user session, shopping cart contents, or dark-mode preference. This is where Provider, Riverpod, or Bloc come in.

Common mistake: Using setState() for app-wide data leads to messy prop-drilling and rebuilds of the entire widget tree. Use it only for local UI state.

02setState() — Local State in Depth

setState() schedules a rebuild of the widget that calls it. Flutter is smart enough to only rebuild the subtree of that particular widget — not the entire screen.

dart
class CounterPage extends StatefulWidget {
  const CounterPage({super.key});
  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;

  // ✅ setState wraps ONLY the mutation — keep it minimal
  void _increment() => setState(() => _counter++);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(
          'Count: $_counter',
          style: Theme.of(context).textTheme.displaySmall,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _increment,
        child: const Icon(Icons.add),
      ),
    );
  }
}

When to stop using setState: The moment you need the same piece of state in more than one widget, or when passing the state down more than 2 widget levels, switch to a state management library.

03Provider & ChangeNotifier

Provider is the Flutter team's officially recommended package for beginners. It wraps Flutter's built-in InheritedWidget in a simpler API. Your state lives in a ChangeNotifier class, and widgets rebuild only when you call notifyListeners().

dart
// 1️⃣  Define your state class
class CartModel extends ChangeNotifier {
  final List<String> _items = [];
  List<String> get items => List.unmodifiable(_items);

  void addItem(String item) {
    _items.add(item);
    notifyListeners(); // ← triggers rebuild in listening widgets
  }

  void removeItem(String item) {
    _items.remove(item);
    notifyListeners();
  }
}

// 2️⃣  Provide it above your widget tree (e.g. in main.dart)
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CartModel(),
      child: const MyApp(),
    ),
  );
}

// 3️⃣  Consume it anywhere in the tree
class CartBadge extends StatelessWidget {
  const CartBadge({super.key});

  @override
  Widget build(BuildContext context) {
    // context.watch() rebuilds when CartModel notifies
    final cart = context.watch<CartModel>();
    return Text('Items: ${cart.items.length}');
  }
}

Use context.read() for one-time reads (e.g. in button callbacks) and context.watch() for reactive rebuilds. Never call context.watch() inside a build()-less callback — it will throw.

04Riverpod — Compile-Safe State

Riverpod was created by the same author as Provider to solve its limitations: providers no longer depend on BuildContext, they can be accessed from anywhere, they are compile-time safe (no runtime "provider not found" errors), and they are easily testable.

dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 1️⃣  Declare a provider at the top level (not inside a widget)
final counterProvider = StateNotifierProvider<CounterNotifier, int>(
  (ref) => CounterNotifier(),
);

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0); // initial value = 0

  void increment() => state++;
  void decrement() => state--;
}

// 2️⃣  Wrap your app with ProviderScope (replaces MultiProvider)
void main() {
  runApp(const ProviderScope(child: MyApp()));
}

// 3️⃣  Consume with ConsumerWidget — no BuildContext required for reading
class CounterDisplay extends ConsumerWidget {
  const CounterDisplay({super.key});

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

// In a callback — ref.read() for one-shot actions
class CounterButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ElevatedButton(
      onPressed: () => ref.read(counterProvider.notifier).increment(),
      child: const Text('Increment'),
    );
  }
}

05Bloc & Cubit — Event-Driven Architecture

Bloc (Business Logic Component) uses reactive streams to separate UI from business logic using a strict Event → Bloc → State flow. Cubit is a simplified version of Bloc without explicit events — great for teams that want the structure without the boilerplate.

dart
// ── CUBIT (simplified Bloc) ──────────────────────────
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
  void reset()     => emit(0);
}

// ── FULL BLOC (event-driven) ──────────────────────────
// Events
abstract class CounterEvent {}
class CounterIncrement extends CounterEvent {}
class CounterReset    extends CounterEvent {}

// Bloc
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrement>((event, emit) => emit(state + 1));
    on<CounterReset>((event, emit) => emit(0));
  }
}

// ── UI using BlocBuilder ──────────────────────────────
class CounterView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<CounterBloc, int>(
      builder: (context, count) => Text('Count: $count'),
    );
  }
}

Bloc is favoured by enterprise teams (banking apps, fintech) because every state transition is explicit, testable, and auditable from the event log.

06How to Choose the Right Tool

SolutionBest ForLearning CurveScalability
setState()Single-widget local UI state⭐ EasyLow
ProviderSmall–medium apps, beginners⭐⭐ EasyMedium
RiverpodMedium–large apps, testability⭐⭐⭐ MediumHigh
Bloc/CubitEnterprise, complex event flows⭐⭐⭐⭐ HardVery High
Key Takeaway

Don't fall into the trap of picking the most popular option. Start with setState, graduate to Provider when you need shared state, and only adopt Riverpod or Bloc once your app complexity genuinely demands it.

Practice State Interactively

Toggle values, watch widgets rebuild in real time, and master reactive state in our interactive State Kingdom challenge.

More Articles