StateNotifierProvider
Managing complex state with methods, immutability, and advanced business logic in Riverpod.
StateNotifierProvider
is the most powerful provider type in Riverpod.
It's designed for managing
complex state
with
methods
,
business logic
, and
immutability
.
🎯 When to Use StateNotifierProvider
Use
StateNotifierProvider
when you need:
- Complex state with multiple fields
- Methods that modify state in specific ways
- Validation and business logic
- Immutable state updates
- Actions like adding, removing, updating items
- Working with lists, forms, or shopping carts
StateNotifierProvider
follows a clear architecture with three main components:
// 1. Define the State (immutable)
class
TodoState
{
final
List<Todo> todos;
final
bool isLoading;
const
TodoState({
required
this
.todos,
required
this
.isLoading});
}
// 2. Define the StateNotifier
class
TodoNotifier
extends
StateNotifier<TodoState> {
TodoNotifier() :
super
(TodoState(todos: [], isLoading:
false
));
// Methods to modify state
void
addTodo
(String title) {
final
newTodo = Todo(title: title);
state = state.copyWith(todos: [...state.todos, newTodo]);
}
}
// 3. Define the Provider
final
todoProvider = StateNotifierProvider<TodoNotifier, TodoState>((ref) {
return
TodoNotifier();
});
Here's a complete example of a todo list app using
StateNotifierProvider
.
import
'package:flutter_riverpod/flutter_riverpod.dart'
;
import
'package:freezed_annotation/freezed_annotation.dart'
;
// 1. Define the Todo model
class
Todo
{
final
String id;
final
String title;
final
bool isCompleted;
Todo({
required
this
.id,
required
this
.title,
this
.isCompleted =
false
,
});
// Copy method for immutability
Todo
copyWith
({String? id, String? title, bool? isCompleted}) {
return
Todo(
id: id ??
this
.id,
title: title ??
this
.title,
isCompleted: isCompleted ??
this
.isCompleted,
);
}
}
// 2. Define the State
class
TodoState
{
final
List<Todo> todos;
final
bool isLoading;
final
String? error;
const
TodoState({
required
this
.todos,
required
this
.isLoading,
this
.error,
});
// Factory for initial state
factory TodoState.initial() {
return
TodoState(
todos: [],
isLoading:
false
,
error:
null
,
);
}
// Copy method
TodoState
copyWith
({
List<Todo>? todos,
bool? isLoading,
String? error,
}) {
return
TodoState(
todos: todos ??
this
.todos,
isLoading: isLoading ??
this
.isLoading,
error: error,
);
}
}
// 3. Define the StateNotifier
class
TodoNotifier
extends
StateNotifier<TodoState> {
TodoNotifier() :
super
(TodoState.initial());
// Add a todo
void
addTodo
(String title) {
if
(title.trim().isEmpty)
return
;
final
newTodo = Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title.trim(),
);
state = state.copyWith(
todos: [...state.todos, newTodo],
);
}
// Toggle completion
void
toggleTodo
(String id) {
state = state.copyWith(
todos: state.todos.map((todo) {
if
(todo.id == id) {
return
todo.copyWith(isCompleted: !todo.isCompleted);
}
return
todo;
}).toList(),
);
}
// Remove a todo
void
removeTodo
(String id) {
state = state.copyWith(
todos: state.todos.where((todo) => todo.id != id).toList(),
);
}
// Clear completed todos
void
clearCompleted
() {
state = state.copyWith(
todos: state.todos.where((todo) => !todo.isCompleted).toList(),
);
}
// Set loading state
void
setLoading
(bool loading) {
state = state.copyWith(isLoading: loading);
}
// Set error state
void
setError
(String error) {
state = state.copyWith(error: error);
}
}
// 4. Define the Provider
final
todoProvider = StateNotifierProvider<TodoNotifier, TodoState>((ref) {
return
TodoNotifier();
});
Here's how to use the
StateNotifierProvider
in your widgets.
import
'package:flutter/material.dart'
;
import
'package:flutter_riverpod/flutter_riverpod.dart'
;
import
'todo_provider.dart'
;
class
TodoScreen
extends
ConsumerWidget {
final
TextEditingController _controller = TextEditingController();
@override
Widget
build
(BuildContext context, WidgetRef ref) {
// Watch the provider to get the state
final
todoState = ref.watch(todoProvider);
// Get the notifier to call methods
final
todoNotifier = ref.read(todoProvider.notifier);
return
Scaffold(
appBar: AppBar(
title: Text(
'Todo List'
),
actions: [
IconButton(
icon: Icon(Icons.delete_sweep),
onPressed: todoState.todos.any((t) => t.isCompleted)
? () => todoNotifier.clearCompleted()
:
null
,
),
],
),
body: Column(
children: [
// Input field
Padding(
padding: EdgeInsets.all(
16
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText:
'Add a todo...'
,
border: OutlineInputBorder(),
),
onSubmitted: (value) {
if
(value.isNotEmpty) {
todoNotifier.addTodo(value);
_controller.clear();
}
},
),
),
SizedBox(width:
8
),
ElevatedButton(
onPressed: () {
if
(_controller.text.isNotEmpty) {
todoNotifier.addTodo(_controller.text);
_controller.clear();
}
},
child: Text(
'Add'
),
),
],
),
),
// Todo list
Expanded(
child: todoState.isLoading
? Center(child: CircularProgressIndicator())
: todoState.error !=
null
? Center(child: Text(
'Error: ${todoState.error}'
))
: todoState.todos.isEmpty
? Center(child: Text(
'No todos yet!'
))
: ListView.builder(
itemCount: todoState.todos.length,
itemBuilder: (context, index) {
final
todo = todoState.todos[index];
return
ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => todoNotifier.toggleTodo(todo.id),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
:
null
,
),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => todoNotifier.removeTodo(todo.id),
),
);
},
),
),
],
),
);
}
}
Todo
is an immutable data class that represents
a single todo item with an id, title, and completion status.
TodoState
holds the entire state of the feature:
the list of todos, loading state, and any error messages.
TodoNotifier
extends
StateNotifier
and holds methods like
addTodo
,
toggleTodo
, and
removeTodo
.
Each method updates the state immutably.
todoProvider
creates and exposes the
TodoNotifier
to the app.
ref.watch(todoProvider)
gives you the state for reading.
ref.read(todoProvider.notifier)
gives you the notifier for calling methods.
✅ Key Pattern
- State is immutable – Always create a new state object, never mutate the existing one
- Methods are actions – Each method represents a user action or business operation
- State updates are predictable – The state only changes through explicit methods
- Separation of concerns – Business logic lives in the notifier, UI only displays
Immutability
is a key principle of
StateNotifierProvider
.
Instead of modifying the existing state, you create a
new copy
with the changes.
// ❌ MUTABLE - Bad practice
class
BadTodoState
{
List<Todo> todos = [];
// Mutable! Can be modified directly
}
// Can be mutated from anywhere
state.todos.add(newTodo);
// ❌ Hard to track changes
// ✅ IMMUTABLE - Good practice
class
GoodTodoState
{
final
List<Todo> todos;
// Immutable!
const
GoodTodoState({
required
this
.todos});
GoodTodoState
copyWith
({List<Todo>? todos}) {
return
GoodTodoState(
todos: todos ??
this
.todos,
);
}
}
// Always create a new state
state = state.copyWith(todos: [...state.todos, newTodo]);
// ✅ Predictable
💡 Benefits of Immutability
- Predictable – State changes are always explicit and traceable
- Debug-friendly – You can see exactly what changed and when
-
Performance
– Flutter can optimize rebuilds (using
const) - Concurrency-safe – No race conditions from shared mutable state
StateNotifierProvider
- Complex state with multiple fields
- Methods with business logic
- Immutable state
- Validation and error handling
- Perfect for forms, lists, shopping carts
StateProvider
- Simple primitive state
- Direct state assignment
- Mutable state
- No business logic
- Perfect for counters, toggles, strings
Don't do
state.todos.add(newTodo)
. This mutates the state without notifying listeners.
Use
state = state.copyWith(todos: [...state.todos, newTodo])
to create a new state.
If your state has loading and error fields, make sure to set and reset them properly.
Set
isLoading = true
before async operations and reset it after.
Don't put business logic in your widgets. Keep the UI focused on rendering.
All validation, transformation, and logic should be in the
StateNotifier
class.
StateNotifierProvider
works great with async operations. Here's how to handle
fetching data from an API.
class
TodoNotifier
extends
StateNotifier<TodoState> {
final
ApiService _api;
TodoNotifier(
this
._api) :
super
(TodoState.initial());
// Fetch todos from API
Future
<
void
>
fetchTodos
() async {
// Set loading state
state = state.copyWith(
isLoading:
true
,
error:
null
,
);
try
{
final
todos = await _api.getTodos();
state = state.copyWith(
todos: todos,
isLoading:
false
,
);
}
catch
(e) {
state = state.copyWith(
isLoading:
false
,
error: e.toString(),
);
}
}
// Add todo to API
Future
<
void
>
addTodo
(String title) async {
try
{
final
newTodo = await _api.createTodo(title);
state = state.copyWith(
todos: [...state.todos, newTodo],
);
}
catch
(e) {
state = state.copyWith(error: e.toString());
}
}
}
// Provider with dependency injection
final
todoProvider = StateNotifierProvider<TodoNotifier, TodoState>((ref) {
final
api = ref.watch(apiServiceProvider);
return
TodoNotifier(api);
});
🎯 Key Takeaway
StateNotifierProvider
is the most powerful provider for complex state management.
It combines
immutable state
with
clear methods
for business logic.
Use it for any feature that requires multiple state fields, validation, or complex operations.
Remember: state is immutable, methods are actions, and the UI just renders.