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.

 

FOUND THIS USEFUL? SHARE IT

Tag -

Dart Flutter

Leave a Reply

Your email address will not be published. Required fields are marked *