Flutter Advanced Lesson 93 of 135 3 min read
MVC in Flutter: Model–View–Controller Explained with an Example
Learn the MVC (Model–View–Controller) pattern in Flutter with a runnable to-do example, what goes in each part, and how MVC compares to MVVM.
On this page
MVC splits code into three roles:
- Model: the data and the rules (a
Todo, “a title cannot be empty”). - View: the widgets the user sees.
- Controller: receives user actions from the view, updates the model, and tells the view to refresh.
User taps → View → Controller → Model changes → View rebuilds
MVC is an architecture pattern, not a package. In Flutter the controller is usually a ChangeNotifier, so the view can listen to it.
A to-do app in MVC
import 'package:flutter/material.dart';
// ---------- Model ----------
class Todo {
Todo(this.title);
final String title;
bool done = false;
}
// ---------- Controller ----------
class TodoController extends ChangeNotifier {
final List<Todo> _todos = [Todo('Read about MVC')];
List<Todo> get todos => List.unmodifiable(_todos);
int get remaining => _todos.where((t) => !t.done).length;
String? add(String title) {
if (title.trim().isEmpty) return 'Title cannot be empty'; // a model rule
_todos.add(Todo(title.trim()));
notifyListeners();
return null;
}
void toggle(Todo todo) {
todo.done = !todo.done;
notifyListeners();
}
}
// ---------- View ----------
void main() => runApp(const MaterialApp(home: TodoPage()));
class TodoPage extends StatefulWidget {
const TodoPage({super.key});
@override
State<TodoPage> createState() => _TodoPageState();
}
class _TodoPageState extends State<TodoPage> {
final controller = TodoController();
final input = TextEditingController();
String? error;
@override
void dispose() {
controller.dispose();
input.dispose();
super.dispose();
}
void _add() {
setState(() => error = controller.add(input.text));
if (error == null) input.clear();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) => Scaffold(
appBar: AppBar(title: Text('MVC to-do (${controller.remaining} left)')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: input,
onSubmitted: (_) => _add(),
decoration: InputDecoration(
hintText: 'New task',
errorText: error,
suffixIcon: IconButton(icon: const Icon(Icons.add), onPressed: _add),
),
),
),
Expanded(
child: ListView(
children: [
for (final t in controller.todos)
CheckboxListTile(
title: Text(t.title),
value: t.done,
onChanged: (_) => controller.toggle(t),
),
],
),
),
],
),
),
);
}
}
In a real project each part goes in its own file: models/todo.dart, controllers/todo_controller.dart, views/todo_page.dart.
MVC vs MVVM
| MVC | MVVM | |
|---|---|---|
| Middle layer | Controller | ViewModel |
| The middle layer knows the view? | Sometimes (it may tell it what to do) | No: the view only listens |
| Exposes | Methods + model | Ready-to-show state (strings, flags, lists) |
| Common in Flutter | Less | More (recommended by the Flutter team) |
In practice the Flutter versions look very similar. The main difference is that a ViewModel prepares data exactly as the screen needs it, so the view stays very simple.
Which state tool goes with MVC?
Any of them: ChangeNotifier (as above), Provider to share the controller, GetX (whose GetxController is an MVC-style controller), or a Cubit.
Try it yourself
Move the controller into a Provider so a second screen can show the number of finished tasks.