Skip to main content

Flutter State Management Guide

A comprehensive guide to Flutter state management patterns with detailed explanations, use cases, and practical examples. This guide covers local state, global state, and popular state management solutions.

Table of Contents

Understanding State Management

What is State Management?

State in Flutter refers to any data that can change over time and affects the UI. State management is the process of managing this data and ensuring the UI updates when the data changes.

Key Concepts:

  • Local State: State that belongs to a single widget
  • Global State: State that needs to be shared across multiple widgets
  • Ephemeral State: Temporary state that doesn't need to be persisted
  • App State: State that needs to be shared and persisted

When to Use Different State Management Approaches

Local State (setState, ValueNotifier):

  • Simple UI interactions
  • Form inputs
  • Animations
  • Widget-specific data

Global State (Provider, Bloc, Cubit):

  • User authentication
  • App settings
  • Shopping carts
  • Data that needs to be shared across screens

Local State Management

setState

What it does: Triggers a rebuild of the widget when state changes.

When to use:

  • Simple local state changes
  • Widget-specific interactions
  • When you need full control over when rebuilds happen

Pros:

  • Simple to understand and implement
  • No external dependencies
  • Good for small widgets

Cons:

  • Can cause unnecessary rebuilds
  • Not suitable for complex state logic
  • Difficult to test
class CounterWidget extends StatefulWidget {

_CounterWidgetState createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
bool _isLoading = false;

void _incrementCounter() {
setState(() {
_counter++;
});
}

void _decrementCounter() {
setState(() {
_counter--;
});
}

void _resetCounter() {
setState(() {
_counter = 0;
});
}


Widget build(BuildContext context) {
return Column(
children: [
Text(
'Count: $_counter',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: _decrementCounter,
child: Text('-'),
),
ElevatedButton(
onPressed: _incrementCounter,
child: Text('+'),
),
ElevatedButton(
onPressed: _resetCounter,
child: Text('Reset'),
),
],
),
],
);
}
}

ValueNotifier

What it does: A simple notifier that holds a single value and notifies listeners when the value changes.

When to use:

  • Simple reactive state
  • When you want more control over rebuilds
  • Single value state management

Pros:

  • More efficient than setState
  • Can be used across multiple widgets
  • Simple to implement

Cons:

  • Limited to single values
  • No built-in state history
  • Manual disposal required
class ValueNotifierExample extends StatefulWidget {

_ValueNotifierExampleState createState() => _ValueNotifierExampleState();
}

class _ValueNotifierExampleState extends State<ValueNotifierExample> {
final ValueNotifier<int> _counter = ValueNotifier(0);
final ValueNotifier<String> _name = ValueNotifier('');


Widget build(BuildContext context) {
return Column(
children: [
// Listen to counter changes
ValueListenableBuilder<int>(
valueListenable: _counter,
builder: (context, value, child) {
return Text(
'Count: $value',
style: TextStyle(fontSize: 24),
);
},
),
SizedBox(height: 16),

// Listen to name changes
ValueListenableBuilder<String>(
valueListenable: _name,
builder: (context, value, child) {
return Text(
'Hello, ${value.isEmpty ? 'Guest' : value}!',
style: TextStyle(fontSize: 18),
);
},
),
SizedBox(height: 16),

// Input field for name
TextField(
decoration: InputDecoration(
labelText: 'Enter your name',
border: OutlineInputBorder(),
),
onChanged: (value) {
_name.value = value;
},
),
SizedBox(height: 16),

// Buttons to update counter
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => _counter.value--,
child: Text('-'),
),
ElevatedButton(
onPressed: () => _counter.value++,
child: Text('+'),
),
],
),
],
);
}


void dispose() {
_counter.dispose();
_name.dispose();
super.dispose();
}
}

ChangeNotifier

What it does: A class that can be extended to provide a change notification API using VoidCallback.

When to use:

  • Multiple related state values
  • When you need custom state logic
  • Simple state management without external dependencies

Pros:

  • Can manage multiple values
  • Custom state logic
  • Built into Flutter

Cons:

  • Manual disposal required
  • No built-in state history
  • Can cause unnecessary rebuilds
class CounterNotifier extends ChangeNotifier {
int _counter = 0;
bool _isLoading = false;

int get counter => _counter;
bool get isLoading => _isLoading;

void increment() {
_counter++;
notifyListeners();
}

void decrement() {
_counter--;
notifyListeners();
}

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

Future<void> incrementAsync() async {
_isLoading = true;
notifyListeners();

await Future.delayed(Duration(seconds: 1));
_counter++;

_isLoading = false;
notifyListeners();
}
}

class ChangeNotifierExample extends StatefulWidget {

_ChangeNotifierExampleState createState() => _ChangeNotifierExampleState();
}

class _ChangeNotifierExampleState extends State<ChangeNotifierExample> {
final CounterNotifier _counterNotifier = CounterNotifier();


Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _counterNotifier,
builder: (context, child) {
return Column(
children: [
Text(
'Count: ${_counterNotifier.counter}',
style: TextStyle(fontSize: 24),
),
if (_counterNotifier.isLoading)
Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: _counterNotifier.decrement,
child: Text('-'),
),
ElevatedButton(
onPressed: _counterNotifier.increment,
child: Text('+'),
),
ElevatedButton(
onPressed: _counterNotifier.reset,
child: Text('Reset'),
),
ElevatedButton(
onPressed: _counterNotifier.incrementAsync,
child: Text('Async +'),
),
],
),
],
);
},
);
}


void dispose() {
_counterNotifier.dispose();
super.dispose();
}
}

Global State Management

Provider

What it does: A wrapper around InheritedWidget to make them easier to use and more reusable.

When to use:

  • Simple global state management
  • When you want a lightweight solution
  • Dependency injection

Pros:

  • Simple to use
  • Good performance
  • Built-in dependency injection
  • Easy to test

Cons:

  • Limited to simple state management
  • No built-in state history
  • Can become complex with large apps
// User model
class User {
final String id;
final String name;
final String email;

User({
required this.id,
required this.name,
required this.email,
});
}

// User provider
class UserProvider extends ChangeNotifier {
User? _user;
bool _isLoading = false;

User? get user => _user;
bool get isLoading => _isLoading;
bool get isLoggedIn => _user != null;

Future<void> login(String email, String password) async {
_isLoading = true;
notifyListeners();

// Simulate API call
await Future.delayed(Duration(seconds: 2));

_user = User(
id: '1',
name: 'John Doe',
email: email,
);

_isLoading = false;
notifyListeners();
}

void logout() {
_user = null;
notifyListeners();
}
}

// App with provider
class AppWithProvider extends StatelessWidget {

Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => UserProvider(),
child: MaterialApp(
home: LoginScreen(),
),
);
}
}

// Login screen
class LoginScreen extends StatefulWidget {

_LoginScreenState createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Login')),
body: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
),
SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
),
SizedBox(height: 16),
Consumer<UserProvider>(
builder: (context, userProvider, child) {
return Column(
children: [
if (userProvider.isLoading)
CircularProgressIndicator()
else
ElevatedButton(
onPressed: () {
userProvider.login(
_emailController.text,
_passwordController.text,
);
},
child: Text('Login'),
),
if (userProvider.isLoggedIn) ...[
SizedBox(height: 16),
Text('Welcome, ${userProvider.user!.name}!'),
ElevatedButton(
onPressed: userProvider.logout,
child: Text('Logout'),
),
],
],
);
},
),
],
),
),
);
}


void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}

Cubit (Bloc Pattern)

What it does: A lightweight state management solution that separates business logic from UI.

When to use:

  • Complex state management
  • When you need predictable state changes
  • Testing business logic
  • Large applications

Pros:

  • Predictable state changes
  • Easy to test
  • Good separation of concerns
  • Built-in state history

Cons:

  • More boilerplate code
  • Learning curve
  • Might be overkill for simple apps
// Counter state
abstract class CounterState {
final int count;
const CounterState(this.count);
}

class CounterInitial extends CounterState {
const CounterInitial() : super(0);
}

class CounterLoading extends CounterState {
const CounterLoading(int count) : super(count);
}

class CounterLoaded extends CounterState {
const CounterLoaded(int count) : super(count);
}

// Counter cubit
class CounterCubit extends Cubit<CounterState> {
CounterCubit() : super(const CounterInitial());

void increment() {
emit(CounterLoaded(state.count + 1));
}

void decrement() {
emit(CounterLoaded(state.count - 1));
}

void reset() {
emit(const CounterInitial());
}

Future<void> incrementAsync() async {
emit(CounterLoading(state.count));

await Future.delayed(Duration(seconds: 1));
emit(CounterLoaded(state.count + 1));
}
}

// App with cubit
class AppWithCubit extends StatelessWidget {

Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (context) => CounterCubit()),
],
child: MaterialApp(
home: CounterScreen(),
),
);
}
}

// Counter screen
class CounterScreen extends StatelessWidget {

Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Counter with Cubit')),
body: BlocBuilder<CounterCubit, CounterState>(
builder: (context, state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (state is CounterLoading)
CircularProgressIndicator()
else
Text(
'Count: ${state.count}',
style: TextStyle(fontSize: 24),
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => context.read<CounterCubit>().decrement(),
child: Text('-'),
),
ElevatedButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: Text('+'),
),
ElevatedButton(
onPressed: () => context.read<CounterCubit>().reset(),
child: Text('Reset'),
),
ElevatedButton(
onPressed: () => context.read<CounterCubit>().incrementAsync(),
child: Text('Async +'),
),
],
),
],
),
);
},
),
);
}
}

Riverpod

What it does: A reactive caching and data-binding framework that's a rewrite of Provider.

When to use:

  • Modern Flutter apps
  • When you want compile-time safety
  • Complex dependency injection
  • Testing

Pros:

  • Compile-time safety
  • Better performance than Provider
  • Easy testing
  • Dependency injection

Cons:

  • Learning curve
  • Newer framework
  • Different syntax from Provider
// Counter notifier with Riverpod
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);

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

Future<void> incrementAsync() async {
await Future.delayed(Duration(seconds: 1));
state++;
}
}

// Provider definition
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});

// App with Riverpod
class AppWithRiverpod extends StatelessWidget {

Widget build(BuildContext context) {
return ProviderScope(
child: MaterialApp(
home: CounterScreen(),
),
);
}
}

// Counter screen
class CounterScreen extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
final counterNotifier = ref.read(counterProvider.notifier);

return Scaffold(
appBar: AppBar(title: Text('Counter with Riverpod')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Count: $count',
style: TextStyle(fontSize: 24),
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: counterNotifier.decrement,
child: Text('-'),
),
ElevatedButton(
onPressed: counterNotifier.increment,
child: Text('+'),
),
ElevatedButton(
onPressed: counterNotifier.reset,
child: Text('Reset'),
),
ElevatedButton(
onPressed: counterNotifier.incrementAsync,
child: Text('Async +'),
),
],
),
],
),
),
);
}
}

State Management Best Practices

Choosing the Right Solution

For Simple Apps:

  • Use setState for local state
  • Use Provider for simple global state

For Medium Apps:

  • Use Provider or Riverpod for global state
  • Use ChangeNotifier for complex local state

For Large Apps:

  • Use Bloc/Cubit or Riverpod for global state
  • Use Provider for dependency injection

State Structure

// Good: Separate concerns
class UserState {
final User? user;
final bool isLoading;
final String? error;

const UserState({
this.user,
this.isLoading = false,
this.error,
});

UserState copyWith({
User? user,
bool? isLoading,
String? error,
}) {
return UserState(
user: user ?? this.user,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
}

// Bad: Mixed concerns
class UserState {
final User? user;
final bool isLoading;
final String? error;
final int counter; // Unrelated state
final List<String> todos; // Unrelated state

// ... rest of the class
}

Error Handling

// Good: Handle errors in state
class DataState<T> {
final T? data;
final bool isLoading;
final String? error;

const DataState({
this.data,
this.isLoading = false,
this.error,
});

bool get hasError => error != null;
bool get hasData => data != null;
}

// Usage in cubit
class DataCubit<T> extends Cubit<DataState<T>> {
DataCubit() : super(const DataState());

Future<void> loadData() async {
try {
emit(DataState(isLoading: true));

final data = await fetchData();
emit(DataState(data: data));
} catch (e) {
emit(DataState(error: e.toString()));
}
}
}

Testing State Management

// Testing a cubit
void main() {
group('CounterCubit', () {
late CounterCubit counterCubit;

setUp(() {
counterCubit = CounterCubit();
});

tearDown(() {
counterCubit.close();
});

test('initial state is 0', () {
expect(counterCubit.state, const CounterInitial());
});

test('increment increases count by 1', () {
counterCubit.increment();
expect(counterCubit.state, const CounterLoaded(1));
});

test('decrement decreases count by 1', () {
counterCubit.increment();
counterCubit.decrement();
expect(counterCubit.state, const CounterLoaded(0));
});

test('reset sets count to 0', () {
counterCubit.increment();
counterCubit.increment();
counterCubit.reset();
expect(counterCubit.state, const CounterInitial());
});
});
}

Performance Considerations

Minimizing Rebuilds

// Good: Use BlocBuilder with condition
BlocBuilder<CounterCubit, CounterState>(
buildWhen: (previous, current) {
// Only rebuild if count changed
return previous.count != current.count;
},
builder: (context, state) {
return Text('Count: ${state.count}');
},
)

// Good: Use Consumer with selector
Consumer<UserProvider>(
builder: (context, userProvider, child) {
// Only rebuild when user changes
return Text('Welcome, ${userProvider.user?.name ?? 'Guest'}!');
},
)

Memory Management

// Good: Dispose resources
class MyWidget extends StatefulWidget {

_MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
late StreamSubscription _subscription;
late Timer _timer;


void initState() {
super.initState();
_subscription = someStream.listen((data) {
// Handle data
});
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
// Handle timer
});
}


void dispose() {
_subscription.cancel();
_timer.cancel();
super.dispose();
}


Widget build(BuildContext context) {
return Container();
}
}

State Persistence

// Using SharedPreferences for persistence
class PersistentCounterCubit extends Cubit<CounterState> {
static const String _key = 'counter_value';

PersistentCounterCubit() : super(const CounterInitial()) {
_loadFromStorage();
}

Future<void> _loadFromStorage() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_key) ?? 0;
emit(CounterLoaded(value));
}

Future<void> _saveToStorage(int value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_key, value);
}

void increment() {
final newValue = state.count + 1;
emit(CounterLoaded(newValue));
_saveToStorage(newValue);
}
}

References