🛜

【Flutter, Dart】状態管理パッケージ「Signals」の紹介

2024/08/06に公開

signalsという状態管理パッケージが面白い仕様だったのでご紹介します。

Signals.dart

signalsはReactの軽量版であるPreactのSignalsに準拠しています。

Signal

int型の値を状態管理したい場合、Flutter Hooks だと以下の書き方になります。

Flutter Hooks の場合
// StatelessWidgetやStatefulWidgetのbuildメソッド内に書く
final counter = useState(0);

// Textウィジットで表示したい時
Text("$counter");

// 値を更新したい時
counter.value += 1;

Signalsだと以下のようになります。

Signalsの場合
// 特に記述場所の制限は無いのでView側とModel側どちらでもOK
late final counter = signal(0);

// Textウィジットで表示したい時
Watch((context) => Text("$counter"));

// 値を更新したい時
counter.value += 1;

Computed

ComputedはRiverpodのProviderや Flutter Hooks のuseMemoizedと役割が似ています。

late final counter = signal(0);
// counterが奇数だったらtrue
late final isOdd = computed(() => counter.value.isOdd);

// Textウィジットで表示したい時
Watch((context) => Text("$isOdd"));

// counterの値を更新するとisOddも再計算される
counter.value += 1;

値の変更を受け取る

Riverpodのref.listenや Flutter Hooks のuseEffectuseValueChangedのように、Flutterの画面更新以外の場所で変更を受け取る方法です。

effect

effect内の関数でvalueを使用すると、そのSignalの値が更新される度にその関数が実行されます。

final dipose = effect(() {
  // 最初に一回、その後はcounterの値が更新される度にprintが呼ばれる
  print(counter);
});

// 戻り値を実行すると変更の受け取りを終了 (今回の場合printが呼ばれなくなる)
dispose();

普通はcounter.listenのように、何の更新を受け取るか直接指定すると思いますが、
signalsの場合はeffectメソッド内で使われているSignalの変更があると自動で呼ばれます。面白いですね。
二つ以上のSignalを使った場合も、そのいずれかのSignalが変更されると呼ばれます。

代わりに最初に一度effect内の関数が実行されます。
今回の場合、例えばStreamのlistenだと1、2、3…とcounterの変更が通知されますが、effectの場合最初に0が出力され、その後1、2、3…となります。

最初に一回実行した際にどのSignalが呼び出されたのかを検出し、そのSignalの変更を受け取る、という仕組みなのだと思います。

subscribe

subscribeSignalを指定して変更を受け取ります。
StreamやRiverpodのlistenと同じように書きたい人用のもので、内部実装はeffectを使用した短いコードになっています。

final dispose = counter.subscribe((value) {
  // 最初に一回、その後はcounterの値が更新される度にprintが呼ばれる
  print(value);
});

// 戻り値を実行すると変更の受け取りを終了 (今回の場合printが呼ばれなくなる)
dispose();

signals_flutterパッケージを使う

先ほどのeffectsubscribeWidgetbuildメソッド内に書くと、画面が再描画される度に値の変更を新しく受け取るようになってしまいます。

build(BuildContext context) {
   // このWidgetが再描画されてbuildが呼ばれる度にeffectも呼ばれてしまう
   effect(() {
     // counterの値が一回更新されるだけで何回もprintが呼ばれてしまう
     print(counter); // 0、1、2、2、3、3、3…
   });
   
  // subscribeも同様
  counter.subscribe((value) {
    print(value); // 0、1、2、2、3、3、3…
  });

  return ...
}

signals_flutterはそれを防ぐための機能が揃っているパッケージです。

使用するにはStatefulWidgetStateSignalsMixinwithします。

class ExamplePage extends StatefulWidget {
  const ExamplePage({super.key});

  
  State<ExamplePage> createState() => ExamplePageState();
}

class ExamplePageState extends State<ExamplePage> with SignalsMixin {
...

次に、effectの代わりにcreateEffectを、subscribeの代わりにthis.listenSignalを使用します。

class ExamplePageState extends State<ExamplePage> with SignalsMixin {
  late final counter = signal(0);
  
  build(BuildContext context) {
    // このWidgetが再描画され、再度呼ばれても一回分のみ通知を受け取る

    // effectの代わり
    createEffect(() {
      print(counter); // 0、1、2、3…
    });
    
    // subscribeの代わり
    this.listenSignal(counter, () {
      print(counter); // 0、1、2、3…
    });

    return ...
  }
}

良い点

  • 他の状態管理パッケージと比べると短く簡潔に書ける
  • Flutter Hooks と違ってWidgetの外で状態管理できる
    • ただしSignalsはRiverpodなどの状態管理パッケージの仲間なので、Widget内のコードを短くするためにある Flutter Hooks とは役割が異なる
  • 変更通知の回数を減らすための機能が多い
    • 本記事では紹介していないがselectuntrackedpeekなどがある
  • runAppの直下に管理用Widgetを置かなくても良いので、他の状態管理パッケージと併用できる
    • Riverpodや Flutter Hooks と共存可能
    • アプリ全体の状態管理はRiverpodを使用し、StatefulWidget内で完結する場合はsetStateの代わりに用いるなど

おわりに

signalsには他にもautoDisposepreviousValueonDisposeなど様々な機能が用意されています。

また、もちろんFutureSignalStreamSignalなどもあります。
https://dartsignals.dev/reference/install/

Signalsは設計も素晴らしいのですが、コードが洗練されているので読んでいて勉強になります。
https://github.com/rodydavis/signals.dart/tree/main/packages

5.3.0 → 5.4.0 の主な変更

Signalsは5.4.0で大きな破壊的変更が行われています。
(deprecateになっているだけなので、5.3.0の記法でも動作します)

本記事は公開当初5.3.0を基に書いていたため、主要な変更点を以下に挙げていきます。

  1. signals_flutterを使用するにはStateSignalsMixinwith(Mixin)するように

  2. createSignalが不要に

5.3.0のcreateSignalがdeprecateになり、this.createSignalが追加されました。
ただし、両者は名前こそ似ているものの全くの別物です。

5.3.0のcreateSignalは、signalの代わりに用いることでコードが簡潔になるものでした。

late final counter = signal(0);
late final counter2 = createSignal(0, context);

Watch((context) => Text("$counter"));
// createSignalの方がすっきりと書ける
Text("$counter2");

ただ、createSignalsignalのどちらもSignal型を返すので判別がしづらく、実質的にコードが複雑化していました。

5.4.0のthis.createSignalsignalの違いは、StatefulWidgetが破棄される際にonDisposeが呼ばれるかどうかしかなく、onDisposeが不要であればsignalだけで問題なくなりました。

ちなみに、onDisposeは React Hooks のクリーンアップに相当する機能ではありますが、個人的にSignalsの概要の説明に必ずしも必須だと感じなかったので、this.createSignalonDisposeの説明は本記事ではしていません。

  1. createEffectの追加

5.3.0ではbuildメソッド内でeffectを使うと不具合が起きるため、listenという単一のSignalの変更だけを受け取るメソッドを使う必要がありました。

5.4.0からはcreateEffectが登場したためlistenはdeprecatedになりました。
this.createListenを使えば以前と同じように単一のSignalの変更だけを受け取ることも可能です。

備考

本記事は以下の環境で検証しました

Flutter 3.24.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 5874a72aa4 (8 days ago) • 2024-08-20 16:46:00 -0500
Engine • revision c9b9d5780d
Tools • Dart 3.5.1 • DevTools 2.37.2
GitHubで編集を提案
合同会社zoome(ズーム)

Discussion