Flutter State Management with ViewModel + Cubit
Introduction
State management is one of the most debated topics in Flutter. Some engineers like Bloc, others prefer Riverpod. But after working on large-scale Flutter apps with dozens of screens and business rules, one thing becomes obvious:
What matters most is not which library you pick, but how you organise responsibilities between UI, business logic, and state.
In this article, I’ll walk you through a ViewModel-driven architecture using Cubit.
Why ViewModel?
In most projects, a single Cubit or Bloc class ends up doing everything:
- Fetching and transforming data
- Validating inputs
- Managing UI state
- Emitting states
As your app grows, this approach becomes hard to scale, test, or reason about. Your Cubit becomes a God class, doing too much, too often.
Solution: ViewModel-Driven Cubit Architecture
To solve this, we can introduce a ViewModel layer
What is a ViewModel?
A ViewModel is a boundary object that manages the state and logic for one specific UI component.
For example:
TeacherViewModel handles teacher-related logic.
StudentViewModel handles student-related logic.
MainViewModel interacts with both ViewModels and connects with the Cubit.
Each ViewModel has two main responsibilities:
refresh(object) → update internal fields with fresh data
build() → return a UI-ready immutable state after necessary logic is performed.
Note:- The view model can contain other logic methods as well.
How It Fits with Cubit:-
- Instead of having Cubit directly manipulate every field, the flow is:
- API/UI provides objects/data (Teacher, Student).
- Cubit calls MainViewModel.refresh(…), passing those objects/data.
- MainViewModel delegates to child ViewModels.
- Cubit calls MainViewModel.build(), and it calls other view models’ build method internally and emits the resulting immutable MainState.
MainState build() => MainState(
teacher: _teacherVM.build(),
student: _studentVM.build(),
);
This keeps a single entry point (MainViewModel) between Cubit and the business logic.
By splitting logic into ViewModels: –
- Cubits become more readable and manageable.
- Each ViewModel can be unit-tested in isolation.
- Teams can scale, one dev works on TeacherViewModel, another on StudentViewModel, without stepping on each other.
Below is the code example:-
UI States:-
State will contain UI variables.
@freezed
class TeacherState with _$TeacherState {
const factory TeacherState({
required String name,
required String subject,
required int experience,
}) = _TeacherState;factory TeacherState.empty() =>
const TeacherState(name: ”, subject: ”, experience: 0);
}@freezed
class StudentState with _$StudentState {
const factory StudentState({
required String name,
required int grade,
required int age,
}) = _StudentState;factory StudentState.empty() =>
const StudentState(name: ”, grade: 0, age: 0);
}@freezed
class MainState with _$MainState {
const factory MainState({
required TeacherState teacher,
required StudentState student,
}) = _MainState;factory MainState.empty() => MainState(
teacher: TeacherState.empty(),
student: StudentState.empty(),
);
}
ViewModels
Each ViewModel mutates private fields and exposes a fresh state from build().
class TeacherViewModel {
String _name = ”;
String _subject = ”;
int _experience = 0;void refresh(Teacher teacher) {
_name = teacher.name;
_subject = teacher.subject;
_experience = teacher.experience;
}TeacherState build() => TeacherState(
name: _name,
subject: _subject,
experience: _experience,
);
}class StudentViewModel {
String _name = ”;
int _grade = 0;
int _age = 0;void refresh(Student student) {
_name = student.name;
_grade = student.grade;
_age = student.age;
}StudentState build() => StudentState(
name: _name,
grade: _grade,
age: _age,
);
}
MainViewModel
Cubit never talks to TeacherViewModel or StudentViewModel directly.
class MainViewModel {
final TeacherViewModel _teacherVM = TeacherViewModel();
final StudentViewModel _studentVM = StudentViewModel();void refresh({required Teacher teacher, required Student student}) {
_teacherVM.refresh(teacher);
_studentVM.refresh(student);
}MainState build() => MainState(
teacher: _teacherVM.build(),
student: _studentVM.build(),
);
}
Cubit
class MainCubit extends Cubit<MainState> {
final MainViewModel _mainVM = MainViewModel();MainCubit() : super(MainState.empty());
void loadData() {
final teacher = TeacherState(name: ‘Mr. Smith’, subject: ‘Math’, experience: 12);
final student = StudentState(name: ‘John’, grade: 8, age: 14);_mainVM.refresh(teacher: teacher, student: student);
emit(_mainVM.build());
}
}
UI
TeacherSection UI
class TeacherSection extends StatelessWidget {
final TeacherState teacher;const TeacherSection({super.key, required this.teacher});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
title: Text(‘Teacher: ${teacher.name}’),
subtitle: Text(
‘Subject: ${teacher.subject}\nExperience: ${teacher.experience} yrs’,
),
trailing: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
// Example action – reload teacher data
context.read<MainCubit>().loadData();
},
),
),
);
}
}
StudentSection UI
class StudentSection extends StatelessWidget {
final StudentState student;
const StudentSection({super.key, required this.student});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
title: Text(‘Student: ${student.name}’),
subtitle: Text(
‘Grade: ${student.grade}\nAge: ${student.age}’,
),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// Add logic here
},
),
),
);
}
}
TeacherStudentScreen
class TeacherStudentScreen extends StatelessWidget {
const TeacherStudentScreen({super.key});@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => MainCubit()..loadData(),
child: BlocBuilder<MainCubit, MainState>(
builder: (context, state) {
return Scaffold(
appBar: AppBar(title: const Text(‘Teacher & Student’)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TeacherSection(teacher: state.teacher),
StudentSection(student: state.student),
],
),
),
);
},
),
);
}
}
Advantages of the view model
Encapsulation → Each ViewModel is a focused unit and will handle specific UI logic/state.
Testability → You can unit test TeacherViewModel without Cubit or UI.
Composable → MainViewModel is just a facade add more child VMs without touching Cubit.
Predictable flow → All updates through refresh() → build() → emit().
Example Unit Test
test(‘TeacherViewModel builds correct state’, () {
final vm = TeacherViewModel();
vm.refresh(const Teacher(name: ‘Dr. Ray’, subject: ‘Chemistry’, experience: 10));
final s = vm.build();
expect(s.name, ‘Dr. Ray’);
expect(s.subject, ‘Chemistry’);
expect(s.experience, 10);
});
Conclusion
This isn’t about inventing yet another state management library. It’s about discipline:
- Keep Cubit clean and focused.
- Move logic into ViewModels.
- Let UI just display the data.
By applying this ViewModel-driven Cubit architecture, your Flutter codebase becomes cleaner, easier to test, and ready to scale — no matter how complex your app grows.
