State Management Best Practices
Why Best Practices Matter
As your Flutter app grows, poorly managed state becomes the #1 source of bugs, performance issues, and developer frustration. Following proven best practices from the start saves you from painful refactors later. In this lesson, we’ll cover the core principles that apply regardless of which state management solution you choose -- whether that’s setState, Provider, Bloc, Riverpod, or anything else.
1. Single Source of Truth
Every piece of state in your app should live in exactly one place. If user data lives in two different objects, they will eventually go out of sync. One object shows the old name while another shows the new name -- and you have a bug that’s incredibly hard to track down.
BAD: Duplicated State
// Anti-pattern: same data in two places
class ProfileScreen extends StatefulWidget {
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
String userName = ''; // Local copy of user name
@override
void initState() {
super.initState();
userName = UserService.currentUser.name; // Copied from service
}
void _updateName(String newName) {
setState(() {
userName = newName; // Updated locally...
});
// But UserService.currentUser.name is now STALE!
// Other screens still show the old name
}
}
// Meanwhile in SettingsScreen...
class _SettingsScreenState extends State<SettingsScreen> {
// This still shows the OLD name because it reads
// from UserService which was never updated
String get displayName => UserService.currentUser.name;
}
GOOD: Single Source of Truth
// The user model lives in ONE place: UserNotifier
class UserNotifier extends ChangeNotifier {
User _user;
UserNotifier(this._user);
User get user => _user;
void updateName(String newName) {
_user = _user.copyWith(name: newName);
notifyListeners(); // All listeners get the update
}
}
// ProfileScreen reads from the single source
class ProfileScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final user = context.watch<UserNotifier>().user;
return Text(user.name); // Always up to date
}
}
// SettingsScreen reads from the SAME single source
class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final user = context.watch<UserNotifier>().user;
return Text(user.name); // Always matches ProfileScreen
}
}
2. Immutable State
Never modify state objects directly. Instead, create new instances with the changes applied. This makes your code predictable, debuggable, and safe from accidental side effects. When state is immutable, you can always compare old vs new to see exactly what changed.
Immutable State Pattern with copyWith
// Immutable state class using copyWith
class AppState {
final List<Todo> todos;
final FilterType filter;
final bool isLoading;
final String? errorMessage;
const AppState({
this.todos = const [],
this.filter = FilterType.all,
this.isLoading = false,
this.errorMessage,
});
// copyWith creates a NEW instance -- never mutates
AppState copyWith({
List<Todo>? todos,
FilterType? filter,
bool? isLoading,
String? errorMessage,
}) {
return AppState(
todos: todos ?? this.todos,
filter: filter ?? this.filter,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
// Derived state -- computed from existing state, never stored separately
int get completedCount => todos.where((t) => t.isCompleted).length;
int get activeCount => todos.length - completedCount;
List<Todo> get filteredTodos {
switch (filter) {
case FilterType.active:
return todos.where((t) => !t.isCompleted).toList();
case FilterType.completed:
return todos.where((t) => t.isCompleted).toList();
case FilterType.all:
return todos;
}
}
}
// Immutable Todo with copyWith
class Todo {
final String id;
final String title;
final bool isCompleted;
final DateTime createdAt;
const Todo({
required this.id,
required this.title,
this.isCompleted = false,
required this.createdAt,
});
Todo copyWith({String? title, bool? isCompleted}) {
return Todo(
id: id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
createdAt: createdAt,
);
}
}
// Usage in a notifier -- always replace, never mutate
class TodoNotifier extends ChangeNotifier {
AppState _state = const AppState();
AppState get state => _state;
void addTodo(String title) {
final newTodo = Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
createdAt: DateTime.now(),
);
// Create NEW list, don't add to existing one
_state = _state.copyWith(todos: [..._state.todos, newTodo]);
notifyListeners();
}
void toggleTodo(String id) {
final updatedTodos = _state.todos.map((todo) {
return todo.id == id
? todo.copyWith(isCompleted: !todo.isCompleted)
: todo;
}).toList();
_state = _state.copyWith(todos: updatedTodos);
notifyListeners();
}
}
_state.todos.add(newTodo) -- this mutates the existing list in place. Widgets that cached a reference to the old list won’t see the change. Always create a new list: [..._state.todos, newTodo].3. Unidirectional Data Flow
Data should flow in one direction: UI dispatches actions → state updates → UI rebuilds. The UI never modifies state directly. This makes it easy to trace where any change came from and why.
Unidirectional Flow in Practice
// FLOW: UI Event -> Action -> State Update -> UI Rebuild
//
// [User taps button]
// |
// v
// [UI calls notifier.addTodo('Buy milk')]
// |
// v
// [TodoNotifier creates new state with the todo added]
// |
// v
// [notifyListeners() fires]
// |
// v
// [All watching widgets rebuild with new state]
class TodoListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// STEP 4: UI reads state and rebuilds
final state = context.watch<TodoNotifier>().state;
return Scaffold(
body: ListView.builder(
itemCount: state.filteredTodos.length,
itemBuilder: (context, index) {
final todo = state.filteredTodos[index];
return CheckboxListTile(
title: Text(todo.title),
value: todo.isCompleted,
// STEP 1: UI dispatches action (user event)
onChanged: (_) {
// STEP 2: Notifier processes action
context.read<TodoNotifier>().toggleTodo(todo.id);
// STEP 3: State is updated, notifyListeners called
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context),
child: const Icon(Icons.add),
),
);
}
}
4. Separation of Concerns: UI vs Business Logic
Your widgets should be dumb -- they display data and forward user events. All business logic (validation, calculations, API calls, data transformations) lives in separate classes. This makes your code testable, reusable, and maintainable.
Separating UI from Business Logic
// BAD: Business logic mixed into the widget
class _BadLoginScreenState extends State<BadLoginScreen> {
bool _isLoading = false;
String? _error;
Future<void> _login() async {
// Validation logic in the widget -- WRONG
if (_emailController.text.isEmpty) {
setState(() => _error = 'Email required');
return;
}
if (!_emailController.text.contains('@')) {
setState(() => _error = 'Invalid email');
return;
}
setState(() => _isLoading = true);
try {
// API call in the widget -- WRONG
final response = await http.post(
Uri.parse('https://api.example.com/login'),
body: {'email': _emailController.text},
);
// JSON parsing in the widget -- WRONG
final data = jsonDecode(response.body);
// Navigation logic in the widget -- messy
Navigator.pushReplacement(context, /*...*/);
} catch (e) {
setState(() => _error = e.toString());
}
setState(() => _isLoading = false);
}
}
// GOOD: Business logic in a separate notifier
class AuthNotifier extends ChangeNotifier {
final AuthRepository _repo;
AuthNotifier(this._repo);
AuthState _state = const AuthState();
AuthState get state => _state;
String? validateEmail(String email) {
if (email.isEmpty) return 'Email is required';
if (!email.contains('@')) return 'Invalid email format';
return null; // null means valid
}
Future<bool> login(String email, String password) async {
final emailError = validateEmail(email);
if (emailError != null) {
_state = _state.copyWith(errorMessage: emailError);
notifyListeners();
return false;
}
_state = _state.copyWith(isLoading: true, errorMessage: null);
notifyListeners();
try {
final user = await _repo.login(email, password);
_state = _state.copyWith(user: user, isLoading: false);
notifyListeners();
return true;
} catch (e) {
_state = _state.copyWith(
isLoading: false,
errorMessage: 'Login failed: \${e.toString()}',
);
notifyListeners();
return false;
}
}
}
// GOOD: Widget is thin -- just displays state and forwards events
class LoginScreen extends StatelessWidget {
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthNotifier>();
return Column(
children: [
TextField(controller: _emailCtrl),
TextField(controller: _passCtrl, obscureText: true),
if (auth.state.errorMessage != null)
Text(auth.state.errorMessage!, style: TextStyle(color: Colors.red)),
ElevatedButton(
onPressed: auth.state.isLoading
? null
: () async {
final success = await auth.login(
_emailCtrl.text,
_passCtrl.text,
);
if (success && context.mounted) {
Navigator.pushReplacementNamed(context, '/home');
}
},
child: auth.state.isLoading
? const CircularProgressIndicator()
: const Text('Login'),
),
],
);
}
}
5. Avoid Unnecessary Rebuilds
Every time notifyListeners() fires, all widgets using context.watch on that notifier will rebuild. If a widget only needs one small piece of data, use Selector or context.select to rebuild only when that specific piece changes.
Optimizing Rebuilds with Selector
// BAD: Rebuilds entire widget when ANY part of state changes
class TodoCounter extends StatelessWidget {
@override
Widget build(BuildContext context) {
// This rebuilds when todos change, filter changes, loading changes...
final state = context.watch<TodoNotifier>().state;
return Text('Active: \${state.activeCount}');
}
}
// GOOD: Only rebuilds when activeCount actually changes
class TodoCounter extends StatelessWidget {
@override
Widget build(BuildContext context) {
final activeCount = context.select<TodoNotifier, int>(
(notifier) => notifier.state.activeCount,
);
return Text('Active: \$activeCount');
}
}
// GOOD: Using Selector widget for the same effect
class TodoCounter extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Selector<TodoNotifier, int>(
selector: (_, notifier) => notifier.state.activeCount,
builder: (context, activeCount, child) {
return Text('Active: \$activeCount');
},
);
}
}
// GOOD: Using Consumer only where needed
class TodoListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Todos'), // Static -- never rebuilds
actions: [
// Only this small part rebuilds when count changes
Consumer<TodoNotifier>(
builder: (context, notifier, child) {
return Badge(
label: Text('\${notifier.state.activeCount}'),
child: child,
);
},
child: const Icon(Icons.list), // Static child -- cached
),
],
),
body: Consumer<TodoNotifier>(
builder: (context, notifier, _) {
return ListView.builder(
itemCount: notifier.state.filteredTodos.length,
itemBuilder: (context, index) {
return TodoTile(todo: notifier.state.filteredTodos[index]);
},
);
},
),
);
}
}
6. Local vs Global State
Not all state needs to be global. Use the right scope for each piece of state:
Local state (setState, widget-level): Form input values, animation controllers, scroll position, bottom sheet open/closed, text field focus, tab selection. Rule: If only one widget cares about it, keep it local.
Scoped state (Provider at subtree level): Shopping cart on e-commerce screens, form wizard multi-step state, feature-specific settings. Rule: If a group of related screens shares it, scope it to their subtree.
Global state (Provider at app level): Authentication/user session, theme/locale preferences, app-wide notifications, feature flags. Rule: If the entire app needs it, make it global.
Scoping State to a Subtree
// Global state -- provided at the app root
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthNotifier()),
ChangeNotifierProvider(create: (_) => ThemeNotifier()),
],
child: const MyApp(),
),
);
}
// Scoped state -- only exists when shopping screens are active
class ShopNavigator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
// CartNotifier is created when user enters the shop section
// and disposed when they leave it
create: (_) => CartNotifier(),
child: Navigator(
onGenerateRoute: (settings) {
// All shop routes share the same CartNotifier
return MaterialPageRoute(
builder: (_) => ShopScreen(),
);
},
),
);
}
}
// Local state -- only this widget cares about it
class ExpandableCard extends StatefulWidget {
final String title;
final String content;
const ExpandableCard({required this.title, required this.content});
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _isExpanded = false; // Pure local state
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
ListTile(
title: Text(widget.title),
trailing: Icon(_isExpanded ? Icons.expand_less : Icons.expand_more),
onTap: () => setState(() => _isExpanded = !_isExpanded),
),
if (_isExpanded) Padding(
padding: const EdgeInsets.all(16),
child: Text(widget.content),
),
],
),
);
}
}
7. Testing Stateful Logic
Because your business logic lives in separate classes (not widgets), you can test it with simple unit tests -- no widget testing needed for logic verification.
Testing State Logic
// test/todo_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
late TodoNotifier notifier;
setUp(() {
notifier = TodoNotifier();
});
group('TodoNotifier', () {
test('starts with empty state', () {
expect(notifier.state.todos, isEmpty);
expect(notifier.state.activeCount, 0);
expect(notifier.state.completedCount, 0);
});
test('addTodo increases count', () {
notifier.addTodo('Buy milk');
expect(notifier.state.todos.length, 1);
expect(notifier.state.activeCount, 1);
expect(notifier.state.todos.first.title, 'Buy milk');
});
test('toggleTodo marks as completed', () {
notifier.addTodo('Buy milk');
final todoId = notifier.state.todos.first.id;
notifier.toggleTodo(todoId);
expect(notifier.state.todos.first.isCompleted, true);
expect(notifier.state.completedCount, 1);
expect(notifier.state.activeCount, 0);
});
test('filter shows only matching todos', () {
notifier.addTodo('Task 1');
notifier.addTodo('Task 2');
notifier.toggleTodo(notifier.state.todos.first.id);
notifier.setFilter(FilterType.active);
expect(notifier.state.filteredTodos.length, 1);
expect(notifier.state.filteredTodos.first.title, 'Task 2');
notifier.setFilter(FilterType.completed);
expect(notifier.state.filteredTodos.length, 1);
expect(notifier.state.filteredTodos.first.title, 'Task 1');
});
test('notifies listeners on state change', () {
int callCount = 0;
notifier.addListener(() => callCount++);
notifier.addTodo('Task');
expect(callCount, 1);
notifier.toggleTodo(notifier.state.todos.first.id);
expect(callCount, 2);
});
});
}
8. State Restoration
When Android kills your app in the background or the user rotates the device, local state is lost. Flutter’s RestorationMixin lets you save and restore ephemeral UI state automatically.
Summary: The Best Practices Checklist
1. Single Source of Truth -- Every piece of state lives in exactly one place
2. Immutable State -- Always create new instances, never mutate
3. Unidirectional Flow -- UI → Action → State Update → UI Rebuild
4. Separation of Concerns -- Thin widgets, fat notifiers/blocs
5. Minimize Rebuilds -- Use Selector/select for targeted rebuilds
6. Right Scope -- Local, scoped, or global as appropriate
7. Test Logic Separately -- Unit test notifiers without widget tests
8. Restore & Persist -- Save UI state with RestorationMixin, persist data to storage