🦔

Recoil公式サイトのTodoアプリを改善する設計手法について

2022/12/24に公開約11,900字

誰???

前書き

Recoil公式サイトに"Interactive Tutorial"として以下のレポジトリのコードが紹介されています。このコードを私個人でリリースしたアプリで使っている設計手法で書き換えてみよう、という趣旨の記事です。

https://github.com/SideGuide/recoil-example

余談ですが、私のアプリは以下のようなことを意識して作っています。

  • ビジネスロジック(ドメイン層)の独立性を担保する
  • チーム開発を想定したRecoilの使い方を模索する
  • レンダリングパフォーマンスに気を遣う
    • ボタン押しっぱなしで数値を加減算できる機能があり、1回の変更によるレンダリングが20ms未満で完了する必要があります(development modeであったとしても)

実際にdevelopment modeで動かした場合、以下のように画面がレンダリングされます。

https://twitter.com/harry0000jp/status/1606582520400973826

DevToolsのPerformanceタブ

前提

この記事は以下の点を前提としています。

  • TypeScriptを使用する
  • ビジネスロジックが非同期処理を持たずpureな処理のみであること

また、対象読者はRecoilのGetting StartedやTutorialなどをすでに読み終わっている方です。そのためRecoilに関する基本的な説明はしません。

この記事の成果物

この記事の成果物はすべて以下のリポジトリに入っています。

https://github.com/harry0000/recoil-todo-app

  • original tag
    • 元のリポジトリからコードをコピーして最低限の型付けを行ったもの
  • refactored tag
    • この記事で行った変更を反映したもの

このTodoアプリの問題点

プロジェクトを新規作成してTypeScriptで最低限の型付けを行った状態で動かしてみます。すると1つの項目のチェックボックスを変えただけですべてのコンポーネントが再レンダリングされてしまい、Recoilを使っている意味がない(?)状態です。

コード変更前の動作

また簡易的なサンプルコードなので、ビジネスロジックが集約されておらず、テストや保守が容易ではありません。

変更作業

1. ビジネスロジックをまとめる

domainディレクトリにビジネスロジックをまとめていきます。

この際の注意点は、Recoilはdevelopment modeで保存する値をfreezeする[1]ことから分かるように、immutableな値が保存されることを期待しています。そのため、objectベースで構築する場合はロジックの実行結果により変更があったら新しいobjectを返すように実装し、classベースの場合はimmutable classとして実装します。

objectベースの実装は元のTodoアプリでも使われていてフロントエンドエンジニアにとってすでになじみ深いものかと思いますので、今回はclassベースで実装してみます。

なるべく元コードの関数名などを維持しつつ、以下のファイルにビジネスロジックを集約します。

https://github.com/harry0000/recoil-todo-app/blob/refactored/src/domain/TodoItem.ts#L1-L39

https://github.com/harry0000/recoil-todo-app/blob/refactored/src/domain/TodoItemContainer.ts#L1-L119

これらの振る舞いをテストする場合、単に普通のclassをテストするのと同じようなテストコードを書くだけです。

2. Recoil state moduleの実装

さて、ビジネスロジックの実装は完了したので後はこれを atom に保存して selector で必要な値を取り出せば終わりでしょうか?

ところがそういうわけにもいかず、selectorget は依存している atom が更新されると再評価が行われるので[2]、不必要な再レンダリングが発生することがあります。 selector のcacheに期待しない(できない)値の場合[3]、基本的に selector の使用を検討するのは、依存する atom が更新された時に必ず変わる値の場合です。ですが、state moduleがビジネスロジック実行後に必ず値が変わる場合かどうか意識しなければならないのは少し不自然です。また今回のTodoアプリにも言えることですが、特定のビジネスロジック実行後にすべての値が変わるのは稀なケースに感じます。

実装方針

ではどのような方針で実装するかというと、以下の3点に従って実装します。

  1. 各コンポーネントが参照する値を格納するためのstateを個別に用意する
  2. state moduleからは以下の2つだけを export する
    • 状態を参照するための RecoilValueReadOnly
    • 状態を変更するための callback function
  3. callbackが呼ばれた際にビジネスロジックを実行し、1で用意したstateに各値を格納する

つまり、各コンポーネントが参照する値を個別に用意し、その変更ロジックをstate module内に隠蔽します。

1に関しては以下の動画サムネイルのように表示する状態を表示する場所だけで使うためです。これにより「実際に変更された値」を参照しているコンポーネントだけが再レンダリングされます。

https://www.youtube.com/watch?v=_ISAA_Jt9kI

ただしこれらの RecoilState をそのままexportすると、任意のコンポーネントで useRecoilState() が呼び出し可能で、どこからどのようにでも状態を変更できてしまい整合性を担保できません[4]

そこでTypeScriptの力を借りて、RecoilStateRecoilValueReadOnly の型にcastしてexportします。これで各コンポーネントはこのstateに対して useRecoilValue() 以外の呼び出しができません。

コード例:

const _foo = atomFamily<boolean, number>({
  key: 'somestate_foo',
  default: false
});

export const fooState: (id: number) => RecoilValueReadOnly<boolean> =
  _fooState(id);

今回用意するstateは以下の通りです。各stateには初期値が必要ですが、それはビジネスロジック側に定義しておきます。

  • Todo毎のテキストと完了状態
  • Todoリストのフィルター
  • フィルターされたTodoリストの一覧
  • Todoの総数
  • 完了したTodoの総数
  • 未完了のTodoの総数
  • Todoの進捗率
  • 未完了のTodoのテキスト一覧

そしてstateの更新はこれとは別にexportするcallbackによって行います。基本的にstateを変更するトリガーを実行するのはstate module外の誰か(何か)なので、callbackをexportしてそのための手段を提供します。

Recoilはcallbackでstateの取得/更新をするための方法を提供していて、このcallbackは実際にはコンポーネントやCustom Hook内で useRecoilCallback() 経由で使用されるので、CallbackInterface => (...any[]) => any のような定義にする必要があります。

https://recoiljs.org/docs/api-reference/core/useRecoilCallback/

今回であれば、以下のような型のcallbackを定義します。

addTodoItem: (cbi: CallbackInterface) => (text: string): void;

editTodoItemText: (cbi: CallbackInterface) => (id: TodoItemId, text: string): void;

toggleTodoItemCompletion: (cbi: CallbackInterface) => (id: TodoItemId): void;

deleteTodoItem: (cbi: CallbackInterface) => (id: TodoItemId): void;

updateFilter: (cbi: CallbackInterface) => (filter: TodoListFilter): void;

CallbackInterface は以下のような型定義になっており、snapshot 経由でのstate取得や set / reset によるstate更新が可能です。

recoil/index.d.ts
type CallbackInterface = {
  snapshot: Snapshot,
  gotoSnapshot: Snapshot => void,
  set: <T>(RecoilState<T>, (T => T) | T) => void,
  reset: <T>(RecoilState<T>) => void,
  refresh: <T>(RecoilValue<T>) => void,
  transact_UNSTABLE: ((TransactionInterface) => void) => void,
};

snapshot経由で取得する例:

ビジネスロジックに非同期処理が存在しない前提なので、すべてのstateを同期的に取得します。

import { GetRecoilValue } from 'recoil';

const get: GetRecoilValue = (recoilVal) => snapshot.getLoadable(recoilVal).getValue();

const someValue = get(someState);

具体的な実装コード

state moduleはvalue/functionベースとclassベースの実装方法が考えられます。

  • value/functionベース
    • moduleのトップレベルにvalueやfunctionを定義する
  • classベース
    • classを定義して、そのsingletonインスタンスをexportする

元のTodoアプリがvalue/functionベースになっており、特に珍しいこともないので、今回はclassベースで実装したいと思います。classベースの場合、classのfieldにRecoil stateを定義するので、複数のインスタンスが作成されてしまうとstateの定義(key)が重複してしまうので、単一のインスタンスだけexportします。

src/state/recoil_state.ts
class TodoState {
  // ...
}

export const todoState = new TodoState();

コンポーネントが参照するstateは前述の通り、定義したものを RecoilValueReadOnly でcastしてexportするだけです。

Todoリストの一覧ですが、各Todoの詳細な情報はそれを表示する末端のコンポーネントで取得するので、atom<ReadonlyArray<TodoItemId>>() と定義して、コンポーネント側ではidをPropsで渡していきます。そしてTodoのテキストと完了状態はそれぞれ atomFamily<string, TodoItemId>()atomFamily<boolean, TodoItemId>() で状態を保持します。

定義部分のコードはここでは省略しますが、1点だけ触れておくと、すべてのstateを横並びで定義してコード補完時などに苦労する場合は、以下のようにまとまった単位で定義することも検討してください。

https://github.com/harry0000/recoil-todo-app/blob/refactored/src/state/recoil_state.ts#L58-L61

さて、各callbackが呼び出されたときにstateに値を格納していく処理を書きましょう。ビジネスロジックのインスタンスを格納する atom を準備して、以下のような #updateState を作ればすべてのcallbackの実装を簡易にできます。

class TodoState {

  readonly #itemContainer = atom({
    key: 'TodoState.#itemContainer',
    default: TodoItemContainer.create()
  });

  // ...

  #updateState = ({ set, reset, snapshot }: CallbackInterface) => (updater: (state: TodoItemContainer) => TodoItemContainer): void => {
    // TODO: implement
  }

  // ...

  readonly addTodoItem = (cbi: CallbackInterface) => (text: string): void => {
    this.#updateState(cbi)(state => state.addItem(text));
  };

  readonly editTodoItemText = (cbi: CallbackInterface) => (id: TodoItemId, text: string): void => {
    this.#updateState(cbi)(state => state.editItemText(id, text));
  };

  readonly toggleTodoItemCompletion = (cbi: CallbackInterface) => (id: TodoItemId): void => {
    this.#updateState(cbi)(state => state.toggleItemCompletion(id));
  };

  readonly deleteTodoItem = (cbi: CallbackInterface) => (id: TodoItemId): void => {
    this.#updateState(cbi)(state => state.deleteItem(id));
  };

  readonly updateFilter = (cbi: CallbackInterface) => (filter: TodoListFilter): void => {
    this.#updateState(cbi)(state => state.setFilter(filter));
  };
}

あとは #updateState の実装をするだけです。ビジネスロジックはimmutable classで実装したので、特に何も考えずに更新後の値を set します。また厳密等価演算子で値の等価性が判別できる型のstateも前の値と一緒であれば、更新の伝播は行われず再レンダリングもされないので、そのまま set します。

問題は配列やobjectなどの場合で、値としては等価だったとしても参照が異なれば厳密等価演算子による比較で false となり、state更新の伝播と再レンダリングが行われてしまいます。そこで、fast-deep-equal で前の値と比較して変更があった場合だけ set します。幸いにも私はまだ fast-deep-equal による比較コストが、比較を行わずに再レンダリングされた場合のコストを上回ったケースに遭遇したことがありません。

また、今回のTodoアプリのように更新結果によって削除が発生することがある場合、前回のstateと比較して削除された項目を洗い出してstateを reset する必要があります。[5]

これらに注意して実装を行うと以下のようになります。

https://github.com/harry0000/recoil-todo-app/blob/refactored/src/state/recoil_state.ts#L63-L97

3. コンポーネントの実装

ようやくコンポーネントでRecoil stateを使う準備が整いました。

実務ではコンポーネント毎にCustom Hookを作成した方がいいと思いますが、今回のサンプルコードにおいてCustom Hookの有無は些細な問題に過ぎないので、コンポーネントで直接 useRecoilValue()useRecoilCallback() を呼び出します。

stateを参照する場合

Recoil公式動画のサムネイルのようにstateを参照する場合はそれを表示するできるだけ末端のDOMノード(コンポーネント)で参照します。

例えば、今回のTodoアプリの中で一番単純な TodoListStats.tsx では以下のようになります。

https://github.com/harry0000/recoil-todo-app/blob/refactored/src/component/TodoListStats.tsx

stateを更新する場合

useRecoilCallback() でcallbackを取得し、それを必要なタイミングで呼び出すだけです。 例えば、TodoItemCreator.tsx では以下のようになります。

https://github.com/harry0000/recoil-todo-app/blob/refactored/src/component/TodoItemCreator.tsx

最後の一押し

すべてのコンポーネントを変更後に動作確認すると、Todoの追加時などにTodo一覧の各行が全て再レンダリングされていることに気がつきます。フィルタリングされたTodo一覧を TodoList.tsx 内の TodoItemList コンポーネントがレンダリングする際に更新してしまっているようです。現状 TodoItem コンポーネントは TodoItemId しかPropsに受け取っていないので、memo化によって再レンダリングを抑制してしまいましょう。

https://github.com/harry0000/recoil-todo-app/blob/refactored/src/component/TodoItem.tsx#L41-L49

変更後

すべての変更を終えた後に動作の確認をしてみましょう。元のTodoアプリとは違い、本当に値が変わったコンポーネントのみ再レンダリングされることが確認できるはずです。

コード変更後の動作

後書き

今回は独立性を担保したビジネスロジックとRecoilを組み合わせて、実際に変更された値を参照してるコンポーネントだけが再レンダリングされるような設計の一例を紹介しました。この記事を読んだ方の設計アイディアの参考になれば幸いです。

Appendix

atomやselectorのkeyについて

基本的には命名規則を決めて文字列で指定するのがよいと考えています。

keyはglobalに一意である必要があるので、1つのMapやobjectで管理する方法も考えられますが、定義した各値が必ずただ一度使用されていることが保証できないと無駄になってしまいます。また重複しないようにkey-valueを定義するためには結局なにかしらの命名規則は必要なので、であればその規則に従って文字列を指定した方が無駄がありません。

value/functionベースとclassベースのstate moduleについて

どちらで実装するかは個人的には好みの問題だと思っていますが、実際、以下のような違いがあります。

value/functionベース

  • すべてが同一スコープ内に定義されるので、コード行数が増えると管理が大変になる
    • 個人の感想です
    • classベースと違いprivate/publicなどのアクセス修飾子の概念もない
    • exportしないvalueやfunctionの名前を _ から始めるなど工夫が必要
  • classベースに比べて、実装時に気をつけることがほぼない

class(singleton)ベース

  • 比較的、fieldやfunctionのコード管理が楽になる
    • 個人の感想です
    • アクセス修飾子により各fieldの公開範囲がエンジニアのコモンセンスで理解できる
    • class外/static/private/publicなどスコープを分けて定義ができる
  • value/functionベースに比べて、実装時に気をつけることが多い

気をつけるべき点として、他のsingletonインスタンスをclass内で参照したとき、参照が相互依存しているとデッドロックしてしまい、それぞれのsingletonインスタンスの生成に失敗してエラーとなることです。

singletonインスタンス相互参照によるインスタンス生成のデッドロック

そのため特定moduleの更新処理で、更新後の値を使って他moduleの更新処理を実行したい場合には工夫が必要となります。

また、基本的にすべてのmemberをfieldとして実装した方が良い、という注意点があります。getterやmethodで実装した場合、singletonインスタンスを以下のように使うと、 thisundefined になるので実行時にエラーが発生します。

import { fooState } from './state/FooState';

const {
  oneState,
  twoState,
  // ... many other states ...
  someCallback
} = fooState;

「Viewにロジックを書くな高校」の歌

「Viewにロジックを書くな高校」の歌

(個人的な経験上、ModelとViewに表示したいものが完全に一致することの方が少ない気がしてるので、ViewModelを挟んだ方がいいと思いました)


脚注
  1. https://recoiljs.org/docs/api-reference/core/atom ↩︎

  2. https://recoiljs.org/docs/api-reference/core/selector#dynamic-dependencies ↩︎

  3. selectorget の戻り値が厳密等価演算子で等価性を判別できない場合など ↩︎

  4. 少なくとも現時点でRecoilは特定のatomの変更に応じて他のatomを変更する機能はありません ↩︎

  5. https://github.com/facebookexperimental/Recoil/issues/622 ↩︎

Discussion

ログインするとコメントできます