When building Flutter apps with state management solutions like ChangeNotifierProvider or StateNotifierProvider, it’s very common to end up duplicating logic across multiple view models.
At first, everything feels fine. Then a few weeks later:
- You copy-paste a method from one ViewModel to another
- You tweak it slightly
- Bugs start diverging
- Maintenance becomes painful
This is usually the point where developers ask:
“Should I extract this into a use case?” or “Can I reuse this logic without over-engineering?”
Problem: Duplicated View Logic
Let’s say you have two ViewModels:
class ProfileViewModel extends ChangeNotifier {
bool isLoading = false;
Future<void> followUser(String userId) async {
isLoading = true;
notifyListeners();
try {
await api.follow(userId);
} finally {
isLoading = false;
notifyListeners();
}
}
}
class PostViewModel extends StateNotifier<PostState> {
PostViewModel() : super(PostState());
Future<void> followUser(String userId) async {
state = state.copyWith(isLoading: true);
try {
await api.follow(userId);
} finally {
state = state.copyWith(isLoading: false);
}
}
}
Same logic. Different state systems. This duplication leads to:
- Inconsistent behavior
- Harder testing
- Increased maintenance cost
Solution: Extract into a Mixin
Instead of duplicating logic, we can move shared behavior into a mixin.
mixin FollowUserMixin {
Future<void> followUser(String userId) async {
setLoading(true);
try {
await performFollow(userId);
} finally {
setLoading(false);
}
}
Future<void> performFollow(String userId);
void setLoading(bool value);
}
Use it in ViewModels
class ProfileViewModel extends ChangeNotifier with FollowUserMixin {
bool isLoading = false;
@override Future<void> performFollow(String userId) {
return api.follow(userId);
}
@override void setLoading(bool value) {
isLoading = value;
notifyListeners();
}
}
class PostViewModel extends StateNotifier<PostState> with FollowUserMixin {
PostViewModel() : super(PostState());
@override Future<void> performFollow(String userId) {
return api.follow(userId);
}
@override void setLoading(bool value) {
state = state.copyWith(isLoading : value);
}
}
Mixins give you:
1. Reusable behavior without inheritance problems
You don’t need a base class that both ViewModels extend.
2. Separation of concerns (lightweight)
- Mixin → how something works
- ViewModel → how state is updated
3. Flexible integration
Each ViewModel adapts the mixin to its own state system.
Mixins vs Use Case Layer
Many developers immediately jump to “clean architecture” and create a use case. But not all duplication deserves a use case.
When to Use a Mixin
Use a mixin if:
- Logic is UI-related
- Depends on ViewModel state
- Needs access to notifyListeners or state
- Slightly varies per ViewModel
Examples:
- Loading handling
- Pagination logic
- Retry logic
- Debouncing user actions
These are presentation concerns, not domain logic.
When to Use a Use Case
Use a use case if:
- Logic is business/domain logic
- Should be shared across multiple layers
- Independent of UI
- Needs to be testable in isolation
class FollowUserUseCase {
final UserRepository repository;
FollowUserUseCase(this.repository);
Future<void> call(String userId) {
return repository.follow(userId);
}
}
Then your ViewModel becomes:
await followUserUseCase(userId);
Thanks for reading.
