【Flutter】Riverpod + Freezed + MVVM で作る、シンプルかつ堅牢なリスト管理アプリ
はじめに

Flutterでの状態管理、どうしていますか? 「Riverpodが良いとは聞くけど、具体的にどうクラスを分ければいいの?」「MVVMってFlutterだとどうなるの?」と悩む方も多いと思います。
今回は、Riverpod Generator と Freezed を組み合わせた、モダンで実務的な MVVM (Model-View-ViewModel) 構成のサンプルアプリを作ってみました。 「ただ動くだけ」ではなく、将来的な拡張やテストのしやすさも考慮した「ちょっと良い設計」を紹介します。
作成するアプリ

ボタンを押すとユーザーリストに新しいユーザーが追加される、非常にシンプルなアプリです。 しかし、その裏側はしっかりとした責務分担(MVVM)がなされています。
アプリのデータの流れ

View (UI) → (操作) → ViewModel (Notifier) → (ロジック実行) → Model (Data) → (新しい状態) → ViewModel → (通知) → View
プロジェクト構成

まずはディレクトリ構成を見てみましょう。役割ごとに綺麗に分かれています。
lib/
├── main.dart
├── models/ # Model: データとビジネスロジック
│ ├── user.dart
│ └── user_list.dart
├── view_models/ # ViewModel: 状態管理とViewへの橋渡し
│ └── user_list_notifier.dart
└── views/ # View: UI描画
└── user_list_page.dart
Step 1. Model (データとロジック)

まずはアプリの主役となるデータ構造です。ここでは freezed パッケージを使って、イミュータブル(不変) なクラスを作成します。
ポイントは、「データの加工ロジックをModelに持たせる」 ことです。
ユーザー単体 (user.dart)
名前を持つだけの最小単位のデータ。まずは最小単位のユーザーモデルを作ります。freezed を使うことで、値の等価性比較(==)などが自動で実装されます。
// lib/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
class User with _$User {
const factory User({required String name}) = _User;
}
ユーザーリスト (user_list.dart)
ユーザーの一覧を束ねるクラス。ここが設計のキモです!単にリストを持つだけでなく、「自分自身に新しいユーザーを追加した新しいリストを返す」 というメソッド (addUser) をここに定義します。
これにより、ViewModel (UserListNotifier) は「Modelのメソッドを呼ぶだけ」というシンプルな役割に徹することができています。
// lib/models/user_list.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:mvvm_riverpod/models/user.dart';
part 'user_list.freezed.dart';
abstract class UserList with _$UserList {
const UserList._();
const factory UserList({required List<User> users}) = _UserList;
// ポイント: ロジックをここに閉じ込める
UserList addUser(User user) {
return copyWith(users: [...users, user]);
}
}
Step 2. ViewModel (状態管理)

次に、ViewとModelをつなぐViewModelです。Riverpod Generator を使ってシンプルに記述します。state として UserList (Model) を持っています。
ここでは UserList (Model) を状態として持ちます。addUser メソッドでは、先ほどModelに定義したロジックを呼び出すだけです。
画面から「追加して!」と言われたら (addUserメソッド)、具体的な計算は Model (state.addUser) に任せ、返ってきた新しい結果を state にセットします。
// lib/view_models/user_list_notifier.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:mvvm_riverpod/models/user.dart';
import 'package:mvvm_riverpod/models/user_list.dart';
part 'user_list_notifier.g.dart';
class UserListNotifier extends _$UserListNotifier {
UserList build() {
// 初期状態を返す
return const UserList(
users: [
User(name: 'AAA'),
User(name: 'BBB'),
User(name: 'CCC'),
],
);
}
// 画面からのアクションを受け取る
void addUser(String name) {
final id = state.users.length + 1;
// Modelのメソッドを使って状態を更新(イミュータブルな更新)
state = state.addUser(User(name: name + id.toString()));
}
}
Step 3. View (UI)

最後に画面の実装です。 ViewModel (userListProvider) を ref.watch で監視し、変更があったら自動で再描画します。リスト表示には ListView.builder を使い、パフォーマンスも考慮します。
ロジック(どうやって追加するか)はここには書かず、notifier.addUser(...) を呼ぶだけに留めています。
// lib/views/user_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_riverpod/view_models/user_list_notifier.dart';
class UserListPage extends ConsumerWidget {
const UserListPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// 状態(Model)を監視
final userListState = ref.watch(userListProvider);
return Scaffold(
body: Column(
children: [
const SizedBox(height: 100),
ElevatedButton(
onPressed: () {
// ViewModelのアクションを呼び出し
ref.read(userListProvider.notifier).addUser('new User');
},
child: const Text('Add User'),
),
Expanded(
// 要素が多くても大丈夫なようにListView.builderを使用
child: ListView.builder(
itemCount: userListState.users.length,
itemBuilder: (context, index) {
final user = userListState.users[index];
return ListTile(title: Text(user.name));
},
),
),
],
),
);
}
}
仕上げ main.dart

最後に ProviderScope でアプリ全体を囲むのを忘れずに。ProviderScopeでアプリ全体を囲むことで、どこからでも ref が使えるようになります。Riverpodを使うための必須設定です。
// lib/main.dart
void main() {
runApp(const ProviderScope(child: App()));
}
この構成のメリット

今回実装したこの構成には、以下のようなメリットがあります。
責務が明確
- データ加工は Model
- 状態管理は ViewModel
- 描画は View
と分かれているため、どこに何を書けばいいか迷いません。
安全な状態更新
Freezed によるイミュータブルなオブジェクト操作のおかげで、意図しないデータの書き換えを防げます。
拡張性
例えば「ユーザーを削除する」機能を追加したい場合、Modelに removeUser メソッドを追加し、ViewModelからそれを呼ぶだけで済みます。
Discussion