【Flutter】Signals x Riverpod x MVVM という新しい設計手法を考えてみた
Hooks vs Signals ではない
最近は「Flutter Hooks かSignalsか」という話をちらほら見聞きしますが、実際に触ってみた限りでは違うように思います。
というのも Flutter Hooks とSignalsは役割が異なるためです。
Flutter Hooks
Widgetを書く際の重複を減らし、コードを短くするためのパッケージ
Signals
Riverpodの書き方をシンプルにし、画面更新を減らす機能を充実させたような状態管理パッケージ
ただしRiverpodのようにアプリ全体の状態管理をするのは不向き
大まかに書くとこのような感じです。全く違いますよね。
共存も可能で Riverpod x Flutter Hooks x Signals も出来ます。
useStateの代わりにsignalを使う
Flutter Hooks は便利なパッケージですが、buildメソッド内で状態管理をする仕組み上、Widgetを分割したり、ロジックを別クラスに移したりするのには苦労します。
useStateで管理していた値をRiverpodのNotifierProviderに移すのが一般的だと思いますが、変数一つ移すのに結構なコード量になりますし、もっと手軽さが欲しいところです。
Signalsの良いところはuseStateと同じように書ける上に、Widgetの内でも外でも状態管理が出来るところなので、useStateの代わりとしてsignalを使用することで解決します。
また、effectというuseEffectと似たようなメソッドがあるのですが、これが面白い仕様となっています。
// buildメソッド内で定義する
final count1 = useState(0);
final count2 = useState(0);
useEffect(() {
print("${count1.value}, ${count2.value}");
return null;
}, [count1.value, count2.value]); // 変更通知を受けたいStateを列挙する
// count1、count2のいずれかの値が変わると上記のprintが実行される
count1.value += 1;
// 基本的にStatefulWidgetか、ViewModel内の変数として定義する
late final count1 = signal(0);
late final count2 = signal(0);
// buildメソッド内ではcreateEffect、それ以外ではeffectを使う
createEffect(() {
print("$count1, $count2");
});
// count1、count2のいずれかの値が変わると上記のprintが実行される
count1.value += 1;
なんと、useEffectでは必要だった変更通知を受けたいStateの列挙が不要になっています。
effect(createEffect) は最初に一度だけ変更が無くても実行されるのですが、その時に呼ばれたsignalを検出する仕組みになっています。楽で良いですね。
今回のサンプルでは Flutter Hooks を使っていませんが、useStateの代わりとしてSignalsを使い、useAnimationやuseTextEditingControllerなどのコードを短くするためのものは Flutter Hooks をそのまま使うのが丁度良い塩梅かなと思います。
カウンターアプリを作る
いよいよ本題のMVVMを作っていきます。
私はInheritedWidgetかProviderパッケージを使ってViewModelを受け渡したい派ですが、多分面倒だと言う方が多そうな気がします笑
ということで、代わりにNotifierProviderでViewModelを管理する方法を考えてみます。
class CounterNotifier extends _$CounterNotifier {
CounterViewModel build() => CounterViewModel();
}
class CounterViewModel {
final counter = signal(0);
CounterViewModel() {
effect(() {
print("$counter");
});
}
}
CounterViewModelというViewModelクラスを作り、CounterNotifierはそれを保持するだけのNotifierProviderとなっています。
buildメソッドは以下のような形。
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.watch(counterNotifierProvider);
return Scaffold(
body: Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Watch((context) => Text('counter: ${vm.counter}')),
ElevatedButton(onPressed: () => vm.counter.value += 1, child: const Text("+1")),
]),
),
);
}
まず、vmという変数名でCounterViewModelをRiverpod経由で取得します。
final vm = ref.watch(counterNotifierProvider);
基本的にViewModelとページは一対一になるのでvmという名前にしていますが、二つ以上のViewModelを取得することがある場合はcounterVmなどの方が良いと思います。
次にsignalを画面上にTextで表示します。
signalをWidget内で使用する時はWatchを使用します。
Watch((context) => Text('counter: ${vm.counter}'))
最後にタップするたびにcounterが加算されるボタンを作ります。
ElevatedButton(onPressed: () => vm.counter.value += 1, child: const Text("+1"))
これでボタンを押すたびに値が1ずつ増え、ついでにprintされるカウンターアプリの完成です。

もっとカウンターアプリを作る
先ほどのカウンターアプリだとシンプルすぎるので、カウンターを三つにしてみます。
class CounterViewModel {
final counter1 = signal(0);
final counter2 = signal(0);
final total = signal(0);
CounterViewModel() {
effect(() {
print("$counter1, $counter2");
// effect内で += や -= を使う時はuntracked内で行う
untracked(() => total.value += 1);
// total.value = total.peek() + 1; でもOK
});
}
}
printでcounter1とcounter2を出力することで変更を監視し、変更がある度にtotalの値を増やしています。
ちなみにprintを使わずにcounter1.value;のように書くだけでもOKです。
Widgetはこんな感じです。
一見ごちゃごちゃしていますが、先ほどのカウンターアプリをcounter1、counter2に増やしただけです。
最後にtotalをボタン抜きのTextだけで追加しています。
return Scaffold(
body: Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Watch((context) => Text('counter1: ${vm.counter1}')),
ElevatedButton(onPressed: () => vm.counter1.value += 1, child: const Text("+1")),
const SizedBox(height: 30),
Watch((context) => Text('counter2: ${vm.counter2}')),
ElevatedButton(onPressed: () => vm.counter2.value += 1, child: const Text("+1")),
const SizedBox(height: 30),
Watch((context) => Text('total: ${vm.total}')),
]),
),
);

さて、これでbuildメソッド内の状態管理とロジックをViewModelに移せるようになりました。
以前と変わらずuseStateも使えるので、自由に選択できるのが良いですね。
Widgetの分割も安易になりました。
final vm = ref.watch(counterNotifierProvider);
を呼べば、同じページ内の別WidgetでもCounterViewModelが呼び出せます。
Widget内が複雑になってきたらsetStateの代わりにsignalを使うとコードが分散できて良いね…、と思っていただけたら幸いですが、一箇所良くないところがあるので最後に修正します。
同じページが二つあっても正しく動作させる
final vm = ref.watch(counterNotifierProvider);
これは同じページ内で共通のCounterViewModelが取得できて便利なのですが、画面遷移後のページでも同じCounterViewModelが共有されてしまうという問題があります。

同じページに遷移することが無いアプリや、ページ遷移後もカウンターの値を引き継ぎたいのであれば問題ありませんが、そうでない場合は遷移後はちゃんと0から始まるようにしたいですね。
対策は簡単で、CounterNotifierに引数を追加するだけです。
class CounterNotifier extends _$CounterNotifier {
- CounterViewModel build() => CounterViewModel();
+ CounterViewModel build(ModalRoute route) => CounterViewModel();
}
そして、CounterViewModelを取得する際にModalRoute.of(context)を引数に渡します。
final vm = ref.watch(counterNotifierProvider(ModalRoute.of(context)!);
画面遷移すると新しいModalRouteが作られる仕様なので、同じページ内ならModalRoute.of(context)で取得できるModalRouteが同じであることを利用しています。
これでページごとに別個のCounterViewModelが取得できるようになりました。

おわりに
もっと設計を深めたい方はヘルパー編もご覧ください。
Signalsの基本的な概要についても以前記事を書いたので、こちらもお読みいただけると嬉しいです。
備考
本記事は以下の環境で検証しました
flutter_hooks: 0.20.5
flutter_riverpod: 2.5.1
riverpod_annotation: 2.3.5
riverpod_generator: 2.4.3
signals: 5.5.0
Flutter 3.24.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 5874a72aa4 (13 days ago) • 2024-08-20 16:46:00 -0500
Engine • revision c9b9d5780d
Tools • Dart 3.5.1 • DevTools 2.37.2
Discussion