ChangeNotifier
Understand how to manage complex state with ChangeNotifier in Flutter.
What is it?
ChangeNotifier is a class that provides change notification functionality. It allows you to create observable objects that can notify listeners when their state changes. ChangeNotifier is the foundation of many state management solutions in Flutter, including Provider, and is ideal for managing complex business logic and application state.
Why does it exist?
ChangeNotifier exists to:
- Manage complex application state
- Notify listeners when state changes
- Support reactive UI updates
- Provide a foundation for state management
- Handle business logic and data models
- Enable separation of concerns
- Support multiple listeners
Basic ChangeNotifier
Creating and using ChangeNotifier.
// Import required packages
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// A simple counter model that extends ChangeNotifier
/// This class manages counter state and notifies listeners of changes
class CounterModel extends ChangeNotifier {
// Private state variable
int _count = 0;
// Getter to expose the count
int get count => _count;
// Method to increment the counter
void increment() {
_count++;
// Notify all listeners that the state has changed
// This triggers UI rebuilds
notifyListeners();
}
// Method to decrement the counter
void decrement() {
_count--;
notifyListeners();
}
// Method to reset the counter to zero
void reset() {
_count = 0;
notifyListeners();
}
}
/// Main application widget
class ChangeNotifierExample extends StatelessWidget {
const ChangeNotifierExample({super.key});
@override
Widget build(BuildContext context) {
// Provide the CounterModel to the widget tree using ChangeNotifierProvider
// This makes the model accessible to all descendant widgets
return ChangeNotifierProvider(
create: (_) => CounterModel(),
child: MaterialApp(
home: const CounterScreen(),
),
);
}
}
/// Screen that displays and interacts with the counter
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
// Access the CounterModel using Provider.of
// This widget will rebuild when the counter changes
final counter = Provider.of<CounterModel>(context);
return Scaffold(
appBar: AppBar(
title: const Text('ChangeNotifier Counter'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Display the current count
// The Text widget will rebuild when count changes
Text(
'Count: ${counter.count}',
style: const TextStyle(fontSize: 24),
),
const SizedBox(height: 16),
// Row of control buttons
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Decrement button
ElevatedButton(
onPressed: counter.decrement,
child: const Text('-'),
),
const SizedBox(width: 8),
// Increment button
ElevatedButton(
onPressed: counter.increment,
child: const Text('+'),
),
const SizedBox(width: 8),
// Reset button
ElevatedButton(
onPressed: counter.reset,
child: const Text('Reset'),
),
],
),
],
),
),
);
}
}
What's happening here?
- CounterModel extends ChangeNotifier to manage state
- notifyListeners() triggers UI updates
- Provider.of
ChangeNotifier with Complex State
Managing complex state and logic.
/// Todo item model representing a single todo
class Todo {
final int id;
final String title;
bool isCompleted;
Todo({
required this.id,
required this.title,
this.isCompleted = false,
});
// Method to toggle completion status
void toggle() {
isCompleted = !isCompleted;
}
// Returns a copy of the todo with updated properties
Todo copyWith({
int? id,
String? title,
bool? isCompleted,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
);
}
}
/// TodoListModel manages a list of todos with ChangeNotifier
class TodoListModel extends ChangeNotifier {
// Private list of todos
List<Todo> _todos = [];
int _nextId = 1;
String _filter = 'all'; // all, active, completed
// Getter for the filtered todo list
List<Todo> get todos {
switch (_filter) {
case 'active':
return _todos.where((todo) => !todo.isCompleted).toList();
case 'completed':
return _todos.where((todo) => todo.isCompleted).toList();
default:
return _todos;
}
}
// Getter for the current filter
String get filter => _filter;
// Getter for total number of todos
int get totalCount => _todos.length;
// Getter for number of active todos
int get activeCount => _todos.where((todo) => !todo.isCompleted).length;
// Getter for number of completed todos
int get completedCount => _todos.where((todo) => todo.isCompleted).length;
// Method to add a new todo
void addTodo(String title) {
// Don't add empty todos
if (title.trim().isEmpty) return;
// Create new todo with a unique id
final todo = Todo(
id: _nextId++,
title: title.trim(),
);
// Add to the list
_todos.add(todo);
// Notify listeners that the list has changed
notifyListeners();
}
// Method to toggle a todo's completion status
void toggleTodo(int id) {
// Find the todo by id
final index = _todos.indexWhere((todo) => todo.id == id);
if (index == -1) return;
// Toggle the todo's completion status
_todos[index].toggle();
// Notify listeners of the change
notifyListeners();
}
// Method to delete a todo
void deleteTodo(int id) {
// Remove the todo from the list
_todos.removeWhere((todo) => todo.id == id);
// Notify listeners of the change
notifyListeners();
}
// Method to clear all completed todos
void clearCompleted() {
_todos.removeWhere((todo) => todo.isCompleted);
notifyListeners();
}
// Method to change the filter
void setFilter(String filter) {
_filter = filter;
notifyListeners();
}
}
/// Main application with TodoListModel
class TodoApp extends StatelessWidget {
const TodoApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => TodoListModel(),
child: MaterialApp(
title: 'Todo App',
home: const TodoScreen(),
),
);
}
}
/// Todo screen with filter controls
class TodoScreen extends StatelessWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the TodoListModel instance
final todoModel = Provider.of<TodoListModel>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Todos'),
// Show clear completed button if there are completed todos
actions: [
if (todoModel.completedCount > 0)
TextButton(
onPressed: todoModel.clearCompleted,
child: const Text(
'Clear Completed',
style: TextStyle(color: Colors.white),
),
),
],
),
body: Column(
children: [
// Input field to add new todos
_buildAddTodoInput(context),
// Filter chips
_buildFilterChips(context),
// Display todo statistics
_buildStats(context),
// Todo list
Expanded(
child: _buildTodoList(context),
),
],
),
);
}
/// Builds the add todo input field
Widget _buildAddTodoInput(BuildContext context) {
final todoModel = Provider.of<TodoListModel>(context, listen: false);
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Text input field
Expanded(
child: TextField(
decoration: const InputDecoration(
hintText: 'Add a new todo...',
border: OutlineInputBorder(),
),
// Add todo when user presses Enter
onSubmitted: (value) => todoModel.addTodo(value),
),
),
const SizedBox(width: 8),
// Add button
ElevatedButton(
onPressed: () {
// Access the TextField's controller to get its value
// In a real app, you'd use a TextEditingController
},
child: const Text('Add'),
),
],
),
);
}
/// Builds filter chips for filtering todos
Widget _buildFilterChips(BuildContext context) {
final todoModel = Provider.of<TodoListModel>(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildFilterChip('All', 'all', todoModel),
_buildFilterChip('Active', 'active', todoModel),
_buildFilterChip('Completed', 'completed', todoModel),
],
),
);
}
/// Helper method to build a single filter chip
Widget _buildFilterChip(String label, String value, TodoListModel model) {
final isSelected = model.filter == value;
return Padding(
padding: const EdgeInsets.all(4),
child: ActionChip(
label: Text(label),
onPressed: () => model.setFilter(value),
backgroundColor: isSelected ? Colors.blue : Colors.grey[200],
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black,
),
),
);
}
/// Builds statistics display
Widget _buildStats(BuildContext context) {
final todoModel = Provider.of<TodoListModel>(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Total: ${todoModel.totalCount}'),
Text('Active: ${todoModel.activeCount}'),
Text('Completed: ${todoModel.completedCount}'),
],
),
);
}
/// Builds the list of todos
Widget _buildTodoList(BuildContext context) {
final todoModel = Provider.of<TodoListModel>(context);
final todos = todoModel.todos;
// Show empty state if no todos
if (todos.isEmpty) {
return const Center(
child: Text('No todos yet. Add one above!'),
);
}
// Build the list of todos
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
// Checkbox to toggle completion
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => todoModel.toggleTodo(todo.id),
),
// Todo title with strikethrough for completed items
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: null,
color: todo.isCompleted ? Colors.grey : null,
),
),
// Delete button
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => todoModel.deleteTodo(todo.id),
),
// Tap the list tile to toggle completion
onTap: () => todoModel.toggleTodo(todo.id),
);
},
);
}
}
What's happening here? - TodoListModel manages complex list state - Methods encapsulate business logic - notifyListeners triggers rebuilds - Computed properties for derived data - Filtering and statistics
ChangeNotifier with Async Operations
Handling async operations with ChangeNotifier.
/// User model for async operations
class User {
final String name;
final String email;
final String avatar;
const User({
required this.name,
required this.email,
required this.avatar,
});
}
/// AsyncUserModel manages user data with async operations
class AsyncUserModel extends ChangeNotifier {
// State variables
User? _user;
bool _isLoading = false;
String? _error;
// Getters
User? get user => _user;
bool get isLoading => _isLoading;
String? get error => _error;
// Method to load user data asynchronously
Future<void> loadUser() async {
// Don't load if already loading
if (_isLoading) return;
// Set loading state
_isLoading = true;
_error = null;
notifyListeners();
try {
// Simulate network request
await Future.delayed(const Duration(seconds: 2));
// Create user data
_user = const User(
name: 'John Doe',
email: 'john@example.com',
avatar: 'https://example.com/avatar.jpg',
);
_isLoading = false;
notifyListeners();
} catch (e) {
// Handle error
_error = e.toString();
_isLoading = false;
notifyListeners();
}
}
// Method to update user data
Future<void> updateUser(String name, String email) async {
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
await Future.delayed(const Duration(seconds: 1));
// Update user with new data
if (_user != null) {
_user = User(
name: name,
email: email,
avatar: _user!.avatar,
);
}
_isLoading = false;
notifyListeners();
} catch (e) {
_error = e.toString();
_isLoading = false;
notifyListeners();
}
}
}
/// Screen for async user operations
class AsyncUserScreen extends StatelessWidget {
const AsyncUserScreen({super.key});
@override
Widget build(BuildContext context) {
final userModel = Provider.of<AsyncUserModel>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Async User'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Loading indicator
if (userModel.isLoading)
const Center(child: CircularProgressIndicator()),
// Error display
if (userModel.error != null)
Container(
padding: const EdgeInsets.all(16),
color: Colors.red[50],
child: Row(
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(
userModel.error!,
style: const TextStyle(color: Colors.red),
),
),
TextButton(
onPressed: userModel.loadUser,
child: const Text('Retry'),
),
],
),
),
// User display
if (userModel.user != null && !userModel.isLoading)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Avatar placeholder
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(50),
),
child: const Icon(
Icons.person,
size: 50,
color: Colors.white,
),
),
const SizedBox(height: 16),
Text(
userModel.user!.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
userModel.user!.email,
style: const TextStyle(fontSize: 16),
),
],
),
),
),
const Spacer(),
// Action buttons
Row(
children: [
// Load user button
Expanded(
child: ElevatedButton(
onPressed: userModel.loadUser,
child: const Text('Load User'),
),
),
const SizedBox(width: 8),
// Update user button
Expanded(
child: ElevatedButton(
onPressed: userModel.user != null
? () {
// In a real app, show a dialog to edit
userModel.updateUser(
'Jane Doe',
'jane@example.com',
);
}
: null,
child: const Text('Update User'),
),
),
],
),
],
),
),
);
}
}
What's happening here? - Async operations with loading states - Error handling and retry - notifyListeners after async completion - UI reflects loading, error, and data states
ChangeNotifier with Multiple Models
Combining multiple ChangeNotifiers.
/// Authentication model
class AuthModel extends ChangeNotifier {
bool _isAuthenticated = false;
String? _username;
bool get isAuthenticated => _isAuthenticated;
String? get username => _username;
void login(String username) {
_isAuthenticated = true;
_username = username;
notifyListeners();
}
void logout() {
_isAuthenticated = false;
_username = null;
notifyListeners();
}
}
/// Settings model
class SettingsModel extends ChangeNotifier {
bool _isDarkMode = false;
String _language = 'en';
bool get isDarkMode => _isDarkMode;
String get language => _language;
void toggleTheme() {
_isDarkMode = !_isDarkMode;
notifyListeners();
}
void setLanguage(String language) {
_language = language;
notifyListeners();
}
}
/// Cart model
class CartModel extends ChangeNotifier {
List<String> _items = [];
List<String> get items => _items;
int get itemCount => _items.length;
void addItem(String item) {
_items.add(item);
notifyListeners();
}
void removeItem(String item) {
_items.remove(item);
notifyListeners();
}
void clearCart() {
_items.clear();
notifyListeners();
}
}
/// App with multiple providers
class MultiModelApp extends StatelessWidget {
const MultiModelApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Provide all models at the root level
ChangeNotifierProvider(create: (_) => AuthModel()),
ChangeNotifierProvider(create: (_) => SettingsModel()),
ChangeNotifierProvider(create: (_) => CartModel()),
],
child: MaterialApp(
home: const MultiModelScreen(),
),
);
}
}
/// Screen using multiple models
class MultiModelScreen extends StatelessWidget {
const MultiModelScreen({super.key});
@override
Widget build(BuildContext context) {
// Access all three models
final auth = Provider.of<AuthModel>(context);
final settings = Provider.of<SettingsModel>(context);
final cart = Provider.of<CartModel>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Multi Models'),
actions: [
// Cart counter in app bar
IconButton(
icon: Badge(
label: Text('${cart.itemCount}'),
child: const Icon(Icons.shopping_cart),
),
onPressed: () {},
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Auth section
Card(
child: ListTile(
title: Text(auth.isAuthenticated
? 'Logged in as ${auth.username}'
: 'Not logged in'),
trailing: ElevatedButton(
onPressed: auth.isAuthenticated
? auth.logout
: () => auth.login('user'),
child: Text(auth.isAuthenticated ? 'Logout' : 'Login'),
),
),
),
// Settings section
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('Dark Mode'),
value: settings.isDarkMode,
onChanged: (_) => settings.toggleTheme(),
),
ListTile(
title: const Text('Language'),
trailing: DropdownButton<String>(
value: settings.language,
items: const [
DropdownMenuItem(value: 'en', child: Text('English')),
DropdownMenuItem(value: 'es', child: Text('Spanish')),
],
onChanged: (value) {
if (value != null) {
settings.setLanguage(value);
}
},
),
),
],
),
),
// Cart section
Expanded(
child: Card(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Cart (${cart.itemCount} items)'),
Row(
children: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
cart.addItem('Item ${cart.itemCount + 1}');
},
),
IconButton(
icon: const Icon(Icons.clear),
onPressed: cart.clearCart,
),
],
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: cart.items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(cart.items[index]),
trailing: IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
cart.removeItem(cart.items[index]);
},
),
);
},
),
),
],
),
),
),
],
),
),
);
}
}
What's happening here? - Multiple ChangeNotifiers for different domains - MultiProvider combines providers - Each model manages its own state - Models are independent and focused
Best Practices
Keep Models Focused
// Good - Focused model
class UserModel extends ChangeNotifier {
// Only user-related state and logic
}
// Bad - God model
class AppModel extends ChangeNotifier {
// Everything in one model
}
Use notifyListeners Correctly
// Good - Notify after changes
void update() {
_value = newValue;
notifyListeners();
}
// Bad - Notify without changes
void update() {
if (_value != newValue) {
_value = newValue;
notifyListeners();
}
}
Separate UI and Logic
// Good - Logic in model
class CounterModel extends ChangeNotifier {
void increment() {
_count++;
notifyListeners();
}
}
// Bad - Logic in UI
ElevatedButton(
onPressed: () {
// Logic in UI - hard to test
counter.value++;
// No notification
},
)
Common Mistakes
Not Calling notifyListeners
Wrong:
void update() {
_value = newValue;
// Missing notifyListeners()
}
Correct:
void update() {
_value = newValue;
notifyListeners();
}
Creating Models in Build
Wrong:
@override
Widget build(BuildContext context) {
final model = CounterModel(); // Created every rebuild
return Text('${model.count}');
}
Correct:
// Create once and provide
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: ...
)
Summary
ChangeNotifier provides reactive state management with change notification. Use ChangeNotifier with Provider for complex app state, async operations, and multiple models. Keep models focused, call notifyListeners after changes, and separate business logic from UI.
Next Steps
Did You Know?
- ChangeNotifier extends Listenable
- notifyListeners triggers UI rebuilds
- Provider integrates with ChangeNotifier
- Multiple listeners can subscribe
- Models can be combined with MultiProvider
- Async operations work with ChangeNotifier
- ChangeNotifier supports dispose
- ChangeNotifier is lightweight and efficient