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.
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().
// 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.
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.
// ── 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
| Solution | Best For | Learning Curve | Scalability |
|---|---|---|---|
| setState() | Single-widget local UI state | ⭐ Easy | Low |
| Provider | Small–medium apps, beginners | ⭐⭐ Easy | Medium |
| Riverpod | Medium–large apps, testability | ⭐⭐⭐ Medium | High |
| Bloc/Cubit | Enterprise, complex event flows | ⭐⭐⭐⭐ Hard | Very High |
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.
