State Management Patterns
Common architectural patterns for organizing and managing state in Riverpod applications.
What is it?
State management patterns are proven ways of structuring your application's state, business logic, and providers.
Riverpod is intentionally flexible—it doesn't force a particular architecture. Instead, it provides the building blocks that work well with patterns such as feature-first architecture, repository pattern, clean architecture, and unidirectional data flow.
Choosing a consistent pattern makes applications easier to understand, test, and scale.
Why does it exist?
Without a consistent pattern, applications often suffer from:
- Business logic inside widgets
- Duplicate code
- Difficult testing
- Tight coupling
- Poor maintainability
A well-defined pattern keeps responsibilities separated and makes the codebase predictable.
Syntax
Keep business logic inside a Notifier
final counterProvider =
NotifierProvider<CounterNotifier, int>(
CounterNotifier.new,
);
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() {
state++;
}
}
Explanation:
- The widget interacts with the provider.
- Business logic remains inside the
Notifier.
Keep repositories separate
final userRepositoryProvider =
Provider<UserRepository>((ref) {
return UserRepository();
});
final userProvider =
FutureProvider<User>((ref) async {
final repository = ref.watch(userRepositoryProvider);
return repository.fetchUser();
});
Explanation:
- The repository handles data access.
- The provider coordinates state.
- UI never talks directly to the repository.
Compose providers
final fullNameProvider = Provider<String>((ref) {
final user = ref.watch(userProvider);
return '${user.firstName} ${user.lastName}';
});
Explanation:
- Derived providers build on existing providers.
- Avoid duplicating state.
Mental Model
UI
│
▼
Provider
│
▼
Notifier
│
▼
Repository
│
▼
API / Database
Each layer has one responsibility:
- UI displays state.
- Providers expose state.
- Notifiers manage business logic.
- Repositories access data sources.
Examples
Simple Example
class TodoNotifier extends Notifier<List<Todo>> {
@override
List<Todo> build() => [];
void add(Todo todo) {
state = [...state, todo];
}
}
Explanation:
- State changes are centralized inside the notifier.
- Widgets remain focused on presentation.
Real-World Example
final authRepositoryProvider =
Provider<AuthRepository>((ref) {
return AuthRepository();
});
final authProvider =
AsyncNotifierProvider<
AuthNotifier,
User?>(
AuthNotifier.new,
);
Explanation:
- Authentication logic lives in the notifier.
- Data access stays in the repository.
- UI simply observes the provider.
When to Use
Use these patterns when:
- Building medium or large applications.
- Working with multiple developers.
- Following Clean Architecture.
- Writing testable business logic.
- Separating UI from data access.
When NOT to Use
Small prototypes or learning projects may not need multiple architectural layers.
Avoid introducing repositories or abstractions that don't provide clear value.
Choose a level of complexity appropriate for the size of the application.
Best Practices
- Keep widgets free of business logic.
- Let notifiers own mutable state.
- Use repositories for data access.
- Compose providers instead of duplicating state.
- Keep providers focused on one responsibility.
- Prefer immutable state models.
- Organize code by feature.
Common Mistakes
Putting business logic inside widgets
Wrong:
ElevatedButton(
onPressed: () async {
final user = await repository.fetchUser();
// Update UI
},
)
Explanation:
- The widget becomes responsible for both UI and business logic.
- Testing becomes more difficult.
Correct:
ElevatedButton(
onPressed: () {
ref.read(userProvider.notifier).loadUser();
},
)
Explanation:
- The widget delegates the work to the notifier.
- Business logic remains centralized.
Accessing repositories directly from the UI
Wrong:
final repository = UserRepository();
await repository.fetchUser();
Explanation:
- The UI becomes tightly coupled to the data layer.
Correct:
final user = ref.watch(userProvider);
Explanation:
- The provider manages state and data access.
- The UI only consumes state.
Duplicating state across providers
Wrong:
final profileProvider = Provider<User>(...);
final accountProvider = Provider<User>(...);
Explanation:
- Multiple providers own the same data.
- Keeping them synchronized becomes difficult.
Correct:
final profileProvider = Provider<Profile>((ref) {
final user = ref.watch(userProvider);
return user.profile;
});
Explanation:
- One provider owns the state.
- Other providers derive values from it.
Related APIs
- Notifier
- AsyncNotifier
- Provider
- NotifierProvider
- AsyncNotifierProvider
- FutureProvider
ref.watch()ref.read()
Summary
A good state management pattern separates presentation, business logic, and data access into distinct layers. Let widgets display state, notifiers manage state changes, providers expose state, and repositories handle external data sources. Following consistent patterns results in applications that are easier to test, maintain, and scale.