Flutter Tutorial

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

MVCMVVM
Middle layerControllerViewModel
The middle layer knows the view?Sometimes (it may tell it what to do)No: the view only listens
ExposesMethods + modelReady-to-show state (strings, flags, lists)
Common in FlutterLessMore (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.

Practise in the playground Updated