🏠 Home / Hub

Flutter 06 — State Management

State management is how your app stores, shares, and updates data across widgets. Choose the right approach for your app's complexity.

1. State Management Overview

There is no single "correct" state management solution. The Flutter team and community offer several options at different complexity levels:

SolutionComplexityBest ForOfficial?
setStateMinimalLocal widget state (counter, toggle, form)Yes (built-in)
InheritedWidgetLowPass data down the tree (Theme, MediaQuery use this internally)Yes (built-in)
ProviderLow-MediumSmall to medium apps; simple shared stateFlutter-recommended
RiverpodMediumMedium to large apps; type-safe, testable, no BuildContext neededCommunity-recommended
Bloc / CubitHighLarge enterprise apps; strict separation of business logicCommunity-popular
GetXLow-MediumAll-in-one (routing + DI + state); fast prototypingCommunity
Start with setState for local state. Use Provider or Riverpod when state needs to be shared across multiple screens.

2. setState — Local State

setState() is the simplest way to update a widget. It triggers a rebuild of the current StatefulWidget and all its descendants.

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

class _CounterPageState extends State<CounterPage> {
  int _count = 0;
  bool _isLoading = false;
  String _status = 'idle';

  // setState rebuilds the widget with the new values
  void _increment() {
    setState(() {
      _count++;
      _status = 'counting';
    });
  }

  void _reset() {
    setState(() {
      _count = 0;
      _status = 'idle';
    });
  }

  // Async setState pattern
  Future<void> _loadData() async {
    setState(() => _isLoading = true);   // show spinner

    await Future.delayed(const Duration(seconds: 2));  // simulate API

    setState(() {
      _isLoading = false;
      _status = 'loaded';
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            if (_isLoading) const CircularProgressIndicator(),
            Text('Count: $_count', style: const TextStyle(fontSize: 48)),
            Text('Status: $_status'),
            const SizedBox(height: 24),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(onPressed: _increment, child: const Text('+')),
                const SizedBox(width: 16),
                ElevatedButton(onPressed: _reset, child: const Text('Reset')),
                const SizedBox(width: 16),
                ElevatedButton(onPressed: _loadData, child: const Text('Load')),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
Use setState only for state that belongs to one widget. If two different screens need the same data, lift it to a shared solution like Provider.

3. Provider Setup

Provider wraps InheritedWidget with a clean API. A ChangeNotifier model holds the data; widgets listen and rebuild when it changes.

# pubspec.yaml
dependencies:
  provider: ^6.1.2
// Wire it up in main.dart
import 'package:provider/provider.dart';

void main() {
  runApp(
    // Provide ONE model to the whole app
    ChangeNotifierProvider(
      create: (context) => CounterModel(),
      child: const MyApp(),
    ),
  );
}

// Provide MULTIPLE models
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => CounterModel()),
        ChangeNotifierProvider(create: (_) => UserModel()),
        ChangeNotifierProvider(create: (_) => CartModel()),
      ],
      child: const MyApp(),
    ),
  );
}

4. ChangeNotifier Models

A ChangeNotifier is a plain Dart class. Call notifyListeners() after updating state to trigger widget rebuilds.

// ---- Counter model ----
import 'package:flutter/foundation.dart';

class CounterModel extends ChangeNotifier {
  int _count = 0;

  int get count => _count;     // expose as getter (read-only from outside)

  void increment() {
    _count++;
    notifyListeners();    // tells all listening widgets to rebuild
  }

  void decrement() {
    if (_count > 0) _count--;
    notifyListeners();
  }

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

// ---- Todo model ----
class Todo {
  final String id;
  final String title;
  bool isDone;
  Todo({required this.id, required this.title, this.isDone = false});
}

class TodoModel extends ChangeNotifier {
  final List<Todo> _todos = [];

  List<Todo> get todos => List.unmodifiable(_todos);
  List<Todo> get pending => _todos.where((t) => !t.isDone).toList();
  int get total => _todos.length;
  int get doneCount => _todos.where((t) => t.isDone).length;

  void add(String title) {
    _todos.add(Todo(id: DateTime.now().millisecondsSinceEpoch.toString(), title: title));
    notifyListeners();
  }

  void toggle(String id) {
    final todo = _todos.firstWhere((t) => t.id == id);
    todo.isDone = !todo.isDone;
    notifyListeners();
  }

  void delete(String id) {
    _todos.removeWhere((t) => t.id == id);
    notifyListeners();
  }
}

5. Consumer and Provider.of

Read the model from the widget tree using Consumer (rebuilds only the Consumer's subtree) or Provider.of.

// ---- Consumer — best practice (fine-grained rebuilds) ----
Consumer<CounterModel>(
  builder: (context, counter, child) {
    // 'child' is an optional widget that doesn't rebuild
    return Column(
      children: [
        Text('${counter.count}', style: const TextStyle(fontSize: 48)),
        // child widget is passed through unchanged
        child ?? const SizedBox(),
      ],
    );
  },
  // This subtree is NOT rebuilt when counter changes
  child: const Text('Static label that never changes'),
)

// ---- Provider.of — read anywhere in build method ----
@override
Widget build(BuildContext context) {
  // listen: true = rebuild when model changes (default)
  final counter = Provider.of<CounterModel>(context);

  // listen: false = read once without subscribing (good for callbacks)
  final counter2 = Provider.of<CounterModel>(context, listen: false);

  return ElevatedButton(
    // Use listen:false in callbacks — you only want to call a method, not rebuild
    onPressed: () => Provider.of<CounterModel>(context, listen: false).increment(),
    child: Text('Count: ${counter.count}'),
  );
}

// ---- context.read / context.watch (shorthand extensions) ----
// context.watch<T>() = Provider.of<T>(context) with listen: true
// context.read<T>()  = Provider.of<T>(context) with listen: false
// context.select<T, R>((m) => m.field) = rebuild only when field changes

@override
Widget build(BuildContext context) {
  final count = context.watch<CounterModel>().count;
  return Text('$count');
}

// In callbacks:
ElevatedButton(
  onPressed: () => context.read<CounterModel>().increment(),
  child: const Text('+'),
)

6. Riverpod Quick Intro

Riverpod is a popular evolution of Provider. It is compile-time safe, doesn't need BuildContext for setup, and scales better for large apps.

# pubspec.yaml
dependencies:
  flutter_riverpod: ^2.5.1
import 'package:flutter_riverpod/flutter_riverpod.dart';

// ---- Define providers at the top level ----

// Simple value provider
final counterProvider = StateProvider<int>((ref) => 0);

// ChangeNotifier provider
final todoProvider = ChangeNotifierProvider((ref) => TodoModel());

// Future provider (automatically handles loading/error states)
final usersProvider = FutureProvider<List<User>>((ref) async {
  return await ApiService.fetchUsers();
});

// ---- Wrap your app with ProviderScope ----
void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

// ---- ConsumerWidget — replaces StatelessWidget ----
class CounterPage extends ConsumerWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watch rebuilds when the provider value changes
    final count = ref.watch(counterProvider);

    return Scaffold(
      body: Center(child: Text('$count', style: const TextStyle(fontSize: 48))),
      floatingActionButton: FloatingActionButton(
        // ref.read — read without watching (use in callbacks)
        onPressed: () => ref.read(counterProvider.notifier).state++,
        child: const Icon(Icons.add),
      ),
    );
  }
}

// ---- ConsumerStatefulWidget — replaces StatefulWidget ----
class TodoPage extends ConsumerStatefulWidget {
  const TodoPage({super.key});
  @override
  ConsumerState<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends ConsumerState<TodoPage> {
  @override
  Widget build(BuildContext context) {
    final todos = ref.watch(todoProvider).todos;
    // ref is available as a field in ConsumerState
    return ListView.builder(
      itemCount: todos.length,
      itemBuilder: (context, i) => ListTile(title: Text(todos[i].title)),
    );
  }
}

7. Bloc / Cubit — Brief Overview

Bloc (Business Logic Component) enforces strict separation of UI and business logic. Events go in, states come out. Cubit is a simplified version without explicit events.

# pubspec.yaml
dependencies:
  flutter_bloc: ^8.1.6

# ---- Cubit (simpler) ----

// The state
class CounterState {
  final int count;
  const CounterState(this.count);
}

// The Cubit — holds logic, emits states
class CounterCubit extends Cubit<CounterState> {
  CounterCubit() : super(const CounterState(0));

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

// Usage
BlocProvider(
  create: (_) => CounterCubit(),
  child: BlocBuilder<CounterCubit, CounterState>(
    builder: (context, state) {
      return Column(
        children: [
          Text('${state.count}'),
          ElevatedButton(
            onPressed: () => context.read<CounterCubit>().increment(),
            child: const Text('+'),
          ),
        ],
      );
    },
  ),
)

// ---- Full Bloc uses Events ----
// Events
abstract class CounterEvent {}
class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}

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

// Dispatch event:
context.read<CounterBloc>().add(Increment());
Bloc adds boilerplate (events + states + bloc classes) but makes large apps predictable and testable. For most apps, Provider or Riverpod is sufficient.

8. State Management Comparison

SolutionWhen to UseLearning CurveBoilerplate
setStateSingle widget local state (toggle, input, counter)BeginnerNone
ProviderApp-wide shared state; small-medium appsBeginnerLow
RiverpodMedium-large apps; prefer type safety and testabilityIntermediateLow-Medium
CubitMedium apps; want some structure without full BlocIntermediateMedium
BlocLarge teams; strict architecture; complex event flowsAdvancedHigh
GetXRapid prototyping; all-in-one solutionBeginnerLow

Decision Guide

Use setState when: only one widget needs the data, the state is transient (form input, animation, toggle).

Use Provider when: data needs to be shared across multiple screens (user session, cart, settings).

Use Riverpod when: you want compile-time safety, no context needed for providers, and better testability.

Use Bloc when: you have a large team, need strict architecture enforcement, or have complex event-driven flows.

📌 Study Checklist