Flutter riverpod + hooks_riverpod の基本的な状態管理の使い方を Vuex (typed-vuex/vuex-module-decorators)と比較して紹介


当社 TORICO ではモバイルアプリ開発のフレームワークに Flutter を採用し、ウェブのフロントエンドのフレームワークとしては Nuxt を採用しています。

普段 Nuxt で開発している方が Flutter での開発を始める際、Flutter での状態管理フレームワークとして、riverpod + hooks_riverpod を使う場合、基本的な使い方を Vuex と比較すると理解が早まります。

状態管理とは

フロントエンド開発の際、「状態管理」という言葉がよく使われます。この「状態」という言葉、最初聞いた時は「この処理はステートフルだ」という言い回しでよく使う「ステート」と同じものだと思っていたのですが、実際はそれより狭い意味であり、「変数の変化をUIの再描画にリアクティブに伝える仕組み」ぐらいに思っておくと良いと思います。

今回の例では、数字の配列から平均値を求めてUIに表示していますが、「配列に値を追加する」という処理を書いただけ(実行するだけ)で、平均値の再計算と必要なUIの再描画が自動で行われます。この一連の自動処理を、「状態管理」と呼ぶと思ってください。

Nuxt + Vuex のカウンターアプリ

Githubでにソースコードを公開しています。

まずは、Nuxt + Vuex でカウンターアプリを作ってみます。上のボタンを押すと、カウンターが増え、下のボタンを押すと、そのカウントを Array に追加します。ついでに Array の平均値も表示するようになっています。

Vuex を TypeScript で書く場合、サポートライブラリとして nuxt-typed-vuex を使う方法と vuex-module-decorators を使う方法がありますが、今回はその両方の比較も兼ねて、両方のパターンで書いてみました。

Nuxt typed-vuex で書いたストア

Github で見る

import {getterTree, mutationTree, actionTree} from 'typed-vuex'

export const state = () => ({
// カウンター
count: 0 as number,
// カウンターの値を追加するリスト
countList: [] as number[]
})

export type RootState = ReturnType<typeof state>

export const getters = getterTree(state, {
// countList の平均値を求める
average: (state) => state.countList.length
? state.countList.reduce((p: number, c: number) => p + c, 0) / state.countList.length
: 0
})

export const mutations = mutationTree(state, {
// カウンターを増加
countUp(state) {
state.count += 1
},
// カウンターリストに追加する
appendCount(state, value: number) {
// nuxt の場合再代入の必要なし。push で再描画される。
state.countList.push(value)
}
})

export const actions = actionTree(
{state, getters, mutations},
{
// 現在のカウンターの値を countList に追加するアクション
async addToCountList({commit, dispatch, getters, state}) {
commit('appendCount', state.count)
}
}
)

Nuxt vuex-module-decorators で書いたストア

Github で見る

import {Module, VuexModule, Mutation, Action} from 'vuex-module-decorators'

@Module({
name: 'counter',
stateFactory: true,
namespaced: true
})
export default class extends VuexModule {
// カウンター
count: number = 0

// カウンターの値を追加するリスト
countList: number[] = []

// countList の平均値を求める
get average() {
return this.countList.length
? this.countList.reduce((p: number, c: number) => p + c, 0) / this.countList.length
: 0
}

// カウンターを増加
@Mutation
countUp() {
this.count += 1
}

// カウンターリストに追加する
@Mutation
appendCount(value: number) {
// vuex の場合再代入の必要なし。push で再描画される。
this.countList.push(value)
}

// 現在のカウンターの値を countList に追加するアクション
@Action({rawError: true})
addToCountList() {
this.appendCount(this.count)
}
}

両方のコードはまったく同じ動作をします。

ポイントとして、 appendCount のミューテーションですが、countList に .push で値を追加しているだけです。

リアクティブに状態の更新を UI に伝えるために、新しい Array を作って countList に代入しなくて大丈夫か、という感じはしますが、vuex の場合は Array や Object がステートに登録された場合、Observer というクラスを介して操作され、内容の変更もリアクティブに再描画されるようになりますので、これで大丈夫です。

Observerは、一部の Array のメソッドが使えなかったり、デバッグ時に値が直接見にくいなどの弊害もあるので、個人的には、この挙動はお節介すぎる気がしますが、理解して使えばコードを短く書けるため、上手に使いましょう。

Flutter + riverpod + hooks_riverpod で書いた場合

これと同様のアプリを Flutter + Riverpod + riverpod hooksで書いています。

Github で見る

counter_controller.dart

Githubで見る

import 'package:hooks_riverpod/hooks_riverpod.dart';

// カウンター
final countState = StateProvider<int>((ref) => 0);

// カウンターの値を追加するリスト
final countListState = StateProvider<List<int>>((ref) => []);

// countListState の平均値を求める
final averageProvider = Provider<double>((ref) {
final productList = ref.watch(countListState);
return productList.isNotEmpty
? productList.fold<int>(0, (v, e) => v + e) / productList.length
: 0.0;
});

final counterController =
Provider.autoDispose((ref) => CounterController(ref.read));

class CounterController {
final Reader _read;

CounterController(this._read);

// カウンターを増加
void countUp() {
// この update は、以下と同じ動作をする
// _read(countState.notifier).state = _read(countState) + 1;
_read(countState.notifier).update((s) => s + 1);
}

// 現在のカウンターの値を countList に追加するアクション
void addToCountList() {
_read(countListState.notifier).update((s) => [...s, _read(countState)]);
}
}

ほぼ同じ感覚で書けています。

vuex store でいう state は、StateProvider を使い、getter は Provider を使って書けます。

mutation は、 StateProvider に対し、 _read(myStateProvider.notifier).state = xxx とします。
これにより、状態の変更が StateProvider を Watch しているウィジェットに伝わり、再描画を行います。

action で書くようなロジックは、それらのメソッドを持つクラス(上記例では CounterController )を作り、そのインスタンスを Provider として作っておくのが良いと思います。 WidgetRef にアクセスさえできればどこに書いても動くのですが、vuex を真似て、StateProvider 等が書いてあるファイル中に一緒にコントローラークラスとして作っておくと、Flutter のプロジェクトも Nuxt のプロジェクトも同じような感覚で修正できるので、楽になるのではないでしょうか。

UI 側のコード(main.dart)の抜粋

Github で見る

class CounterWidget extends HookConsumerWidget {
const CounterWidget({Key? key}) : super(key: key);

@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(countState);

return Column(children: [
Text(count.toString(), style: const TextStyle(fontSize: 48)),
ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
),
onPressed: () {
ref.read(counterController).countUp();
},
child: const Text('CountUp'))
]);
}
}

HookConsumerWidget を継承したウィジェットを作り、その中で ref.watch すると、その変数が更新された時に UI が再描画されます。

現在の評価: 5

コメント

コメントを投稿
コメントするには TORICO-ID にログインしてください。
ログイン コメント利用規約