spacelyのブログ

Spacely Engineer's Blog

Recoil で管理する状態を atom から atomFamily に変更した話

目次

はじめに

フルスタックエンジニアとして開発を行っている五十嵐です。弊社でリリースしている パノラマ変換 3D プレイヤー について、サーバサイドの Ruby on Rails とフロントエンドの React を使った開発を担当しています。

弊社ではフロントエンドの状態管理ライブラリとして Recoil を採用しています。したがって Recoil については私もよく調べるのですが、どういった状態管理ライブラリなのかの説明や、導入に至った経緯、簡単な使い方の説明などの記事をよく目にします。しかし、個人的には具体的な活用事例やそれについての実装プロセスはあまり記事化されていない印象のため、この記事では Recoil を使った弊社のアプリケーションの実装の際に見えてきた課題とどのように対応したかを、具体例となるデータ構造や機能要件を交えてご紹介しようと思います。

状態管理したいデータ構造と機能要件

今回取り挙げる機能は弊ブログですでに紹介した VR の部屋に家具を置いてみた です。AI 空間設計という機能名で提供しており、AI 空間設計は作成した VR 空間上に自由に家具を配置できる機能です。この記事は投稿から時間が経っていて、実はこの機能は新しく 3D プレイヤー上でシームレスに体験できる機能としてアップデートされています。その様子がこちらです。

新しくなったAI空間設計の様子
新しくなったAI空間設計の様子

ただし、レイアウト、部屋、家具というデータの構造自体は変わっていません。

以下にデータ構造の ER 図をお見せします。

AI空間設計で扱うデータ構造
AI空間設計で扱うデータ構造

図の通り「レイアウト : 部屋 = 1 : N」であり「部屋 : 家具 = 1 : N」です。サーバサイドから取得したこれらのデータをプレイヤー (フロントエンド) 上で表示します。それぞれの役割を簡単に説明しますと、この部屋を寝室として使うパターンが欲しい、この部屋のテレビとテーブルの位置のパターンを複数欲しいといった、同じ部屋でも複数のパターンを保存できるためにレイアウトが存在します。そしてもちろん家には部屋が複数あるので、レイアウトは複数の部屋持ちます。加えて、各部屋毎に複数の家具を持つ、という構造になっています。ちなみにフロントへ渡されるレイアウトは複数です。

ここで、ひとえに状態管理しましょうといっても、独自の機能要件を満たせる様な効率的でかつ可読性の高いコードを書くことが求められます。3D 上で表現された部屋の中で家具を置くというアプリケーションなので他にも細かい機能要件はたくさんありますが、今回はデータとしては以下の要件を満たせれば良いということにします。

  1. レイアウトの追加・更新・削除が行える必要がある
  2. 家具データの追加・更新・削除が行える必要がある

では、この機能要件を満たすために、どのように状態管理を行なったかを説明していきます。

atom と atomFamily について

次に進む前にまずは Recoil の状態を表す atom と atomFamily について簡単に説明します。

atom

atom は Recoil のもつ状態です。 atom() 関数は書き込み可能な RecoilState オブジェクトを返します。たとえば、以下のようなコードを書くことで、count という状態を管理できます。

const countState = atom<number>({
  key: 'Count',
  default: 0,
})
atomFamily

atomFamilyatom の集合を表します。 atomFamily() 関数は渡されたパラメータに基づいて atom を提供する関数が返されます。公式ドキュメントの通りパラメータに相当する atom を返すので、atomFamily は atom と並列に扱われる state というよりは atom を返すユーティリティ関数です。したがって 1 というパラメータを指定した上で座標の配列を更新し、一方で 2 というパラメータを指定した上で座標の配列を更新するなど、複数の状態を管理できます。たとえば、以下のようなコードを書くことで複数の position という状態を管理できます。

const positionState = atomFamily<number[], number>({
  key: 'Position',
  default: [0, 0],
})
atom と atomFamily の使い分け

atom と atomFamily はどちらも状態を管理するための関数ですが、atomFamily は atom の集合を表すため、atomFamily を使うことで atom を複数作成でき、状態の管理をより柔軟に行うことができます。また、公式ドキュメントには以下の通り書かれています (公式ドキュメント) 。

For example, maybe your app is a UI prototyping tool where the user can dynamically add elements and each element has state, such as its position. Ideally, each element would get its own atom of state. You could implement this yourself via a memoization pattern. But, Recoil provides this pattern for you with the atomFamily() utility. An Atom Family represents a collection of atoms.

例えば、あなたのアプリが UI プロトタイピング・ツールで、ユーザーが動的に要素を追加でき、各要素が位置などのステートを持つとする。理想的なのは、各要素がそれ自身のアトムの状態を持つことだ。これをメモ化パターンを使って自分で実装することもできる。しかし、Recoil は atomFamily() ユーティリティでこのパターンを提供してくれます。アトムファミリはアトムの集合を表します。 (DeepL による翻訳)

まさに今回のアプリケーションの家具がそれに当たります。家具は複数存在し、それぞれの家具の座標を頻繁に更新する必要があります。したがって、atomFamily を使う方がベターということになります。

atomFamily を使った実装

余談ですが、実は前任者によってすでに atom を使った実装がされており、機能要件も実現されていました。ただし可読性が低く、機能要件を満たすために冗長なコードが書かれており、保守性が高いとは言えませんでした。そのため今回の記事の実装方針は私が実際にリファクタリングした内容になります。そして前任者とか言いましたが恥ずかしながら数ヶ月前の自分です。自分で書いたコードが自分でも読むのに苦労し始め、このままではチームメンバーにタスクを渡せない...と感じてきたのでリファクタリングに踏み切ったという経緯です。

そのため、今回の記事では実装と比較しながら説明していきます。

サーバサイドから取得する JSON は以下のような形式です。それぞれのプロパティはまだまだありますが、必要な部分を抽出しました。家具の座標データも本来は 3D (x, y, z) の形式ですが、今回は簡易化して 2D (x, y) の形式にしています。

{
  "layouts": {
    "1": {
      "id": "1",
      "title": "レイアウト1",
      "roomIds": ["1", "2"]
    },
    "2": {
      "id": "2",
      "title": "レイアウト2",
      "roomIds": ["3", "4"]
    }
  },
  "rooms": {
    "1": {
      "id": "1",
      "furnitureItemIds": ["1", "2"]
    },
    "2": {
      "id": "2",
      "furnitureItemIds": ["3"]
    },
    "3": {
      "id": "3",
      "furnitureItemIds": ["4"]
    },
    "4": {
      "id": "4",
      "furnitureItemIds": []
    }
  },
  "furnitureItems": {
    "1": {
      "id": "1",
      "x": 0.0,
      "y": 0.5
    },
    "2": {
      "id": "2",
      "x": -0.1,
      "y": -0.2
    },
    "3": {
      "id": "3",
      "x": 0.1,
      "y": 0.2
    },
    "4": {
      "id": "4",
      "x": -0.2,
      "y": 0.1
    }
  }
}

atom で実装した場合

上の JSON に沿って atom を作ると以下のようになります。

const layoutsState = atom<Record<string, Layout>>({
  key: 'Layouts',
  default: {},
})

const roomsState = atom<Record<string, Room>>({
  key: 'Rooms',
  default: {},
})

const furnitureItemsState = atom<Record<string, FurnitureItem>>({
  key: 'FurnitureItems',
  default: {},
})

ポイントとしてはすべて Record 型 (参考: TypeScript > Utility Type > Record<Keys, Type>) のオブジェクトというところです。それぞれのデータを JSON の形式の通りに受け取ってそのまま使いたいのでこの様な形になります。

ただし、これには問題があります。たとえば家具 ID: 1 の座標を更新したい場合、以下のようなコードを書くことになります。 (サンプルは react-draggable から拝借しました。変更を加えているので動作は保証できません。)

export const FurnitureItem = ({ id }: { id: string }) => {
  const setFurnitureItems = useSetRecoilState(furnitureItemsState)

  const handleDrag = useCallback((e: MouseEvent, data: Object) => {
    setFurnitureItems((prevFurnitureItems) => {
      // すべての家具データをマッピングしたオブジェクトからID: 1 に対応するデータを取得する
      const prevFurnitureItem = prevFurnitureItems[id]

      // 座標を更新した家具の新しいオブジェクトを生成する
      const newFurnitureItem = {
        ...prevFurnitureItem,
        x: prevFurnitureItem.x + data.deltaX,
        y: prevFurnitureItem.y + data.deltaY,
      }

      // すべての家具データをマッピングしたオブジェクトに新しいオブジェクトを追加したオブジェクトを返す
      return {
        ...prevFurnitureItems,
        [id]: newFurnitureItem,
      }
    })
  }, [])

  return (
    <Draggable
      axis='x'
      handle='.handle'
      defaultPosition={{ x: 0, y: 0 }}
      position={null}
      grid={[25, 25]}
      scale={1}
      onDrag={handleDrag}
    >
      <div>
        <div className='handle'>Drag from here</div>
        <div>This readme is really dragging on...</div>
      </div>
    </Draggable>
  )
}

handleDrag が家具の座標を更新するための関数で、その中で Recoil の set 関数である setFurnitureItems を呼び出しています。その中では、まずすべての家具データをマッピングしたオブジェクト (セット前の状態である prevFurnitureItems) を呼び出します。そこからターゲットとなる家具を ID から指定します。直接のプロパティへの書き込みは基本的には禁止されているため、新たなオブジェクトを生成し、セット前の値を展開した上で、新しい家具データをセットします。最後に、セット前のオブジェクトに新しい家具を追加したオブジェクトを返します。

このように、任意のプロパティを更新するためにかなり冗長なコードを書く必要があります。また、これは家具の例ですがレイアウトに対しても行う必要があり、状態を更新するためのコードだけでかなりコード量が増えることが簡単に予想されます。

他の更新の手段としては immer の様なライブラリを使うことも可能ですが、そもそも今回については状態管理自体に課題があると考えました。更新を例に挙げましたが、他にも追加や削除、取得するだけでもすべてのデータを取得の上で ID を使っての指定が必要になり、それらは非常に冗長です。したがってこういった場合は atom ではなく atomFamily を使うべきだと判断しました。

atomFamily で実装した場合

たとえば上の例をすべて atomFamily で実装すると以下のようになります。

const layoutsState = atomFamily<Layout, string>({
  key: 'Layouts',
  default: undefined,
})

const roomsState = atomFamily<Room, string>({
  key: 'Rooms',
  default: undefined,
})

const furnitureItemsState = atomFamily<FurnitureItem, string>({
  key: 'FurnitureItems',
  default: undefined,
})

では先ほどと同じ様に家具 ID: 1 の座標を更新するコードを書いてみます

export const FurnitureItem = ({ id }: { id: string }) => {
  const setFurnitureItem = useSetRecoilState(furnitureItemsState(id))

  const handleDrag = useCallback((e: MouseEvent, data: Object) => {
    setFurnitureItem((prevFurnitureItem) => ({
      ...prevFurnitureItem,
      x: prevFurnitureItem.x + data.deltaX,
      y: prevFurnitureItem.y + data.deltaY,
    }))
  }, [])

  return (
    <Draggable
      axis='x'
      handle='.handle'
      defaultPosition={{ x: 0, y: 0 }}
      position={null}
      grid={[25, 25]}
      scale={1}
      onDrag={handleDrag}
    >
      <div>
        <div className='handle'>Drag from here</div>
        <div>This readme is really dragging on...</div>
      </div>
    </Draggable>
  )
}

handleDrag 内の処理がシンプルになったかと思います。たかが数行ではありますがオブジェクトの中からデータを指定するなど冗長な処理を省くことができました。また、今回の JSON データでは家具の量は少なかったですが、これが数十や数百件になるとそれに応じてオブジェクトも大きくなり、ただ 1 つのデータのプロパティを更新したいだけなのにもかかわらず大きなデータを扱うようなムダな処理が必要になってしまいます。サンプルとして挙げた handleDrag も今は簡単な関数ですので、他の処理が内包されてたら、またはされる可能性を考えるとなるべく冗長な処理を省くことは大切かと思います。

それ以上に、 React では頻繁に議論される再レンダリングについて、 atomFamily を使うことにより再レンダリングの回数を減らすことができます。具体的には、すべてのデータを読み込んでいるため任意のデータが更新されても再レンダリングされていたものを、必要なデータのみを更新したり読み込むことができるようになりました。

以下が layoutsState (atom) を使った時のコンポーネントです。たとえば任意のレイアウトである LayoutComponent での更新処理でも layoutsState が更新されるので LayoutsComponent が再レンダリングされます。

export const LayoutsComponent = () => {
  const layouts = useRecoilValue(layoutsState)
  ...

  return layouts.map((layout) => <Layout key={layout.id} layout={layout} />)
}

export const LayoutComponent = ({ layout }: { layout: Layout }) => {
  ...

  // 任意のレイアウトを更新する処理を含んだコンポーネント
  return <div>...</div>
}

以下が layoutIdsState (atom) と layoutsState (atomFamily) を使った時のコンポーネントです。この場合だと任意のレイアウトである LayoutComponent での更新処理では layoutIdsState は更新されないので LayoutsComponent は再レンダリングされません。

export const LayoutsComponent = () => {
  const layoutIds = useRecoilValue(layoutIdsState)
  ...

  return layoutIds.map((layoutId) => <Layout key={layoutId} layoutId={layoutId} />)
}

export const LayoutComponent = ({ layoutId }: { layoutId: string }) => {
  const layout = useRecoilValue(layoutsState(layoutId))
  ...

  // 任意のレイアウトを更新する処理を含んだコンポーネント
  return <div>...</div>
}

layoutIdsState とはすべてのレイアウトの ID を配列として持つための状態です。 atomFamily にすることにより 同じコンポーネントの構成で状態が 2 つに分かれたという課題はあり、それは後述します。ただし、もちろんコンポーネントレベルでの最適化で補える部分もあるかと思いますが、状態管理の段階でできる部分はできるだけやっておきたいというのが私の考えです。また、このおかげでコンポーネントもシンプルになり、可読性が高まったと感じています。

atomFamily だからこその課題

では「コレクションの様なデータ構造で任意のデータのプロパティを更新する場合 atomFamily を使えば良い」ということでしょうか。実は一概にはそうは言えないというのが私の考えです。atomFamily を使うことで解決できる課題がある一方で、新たな課題が生まれます。

atomFamily からデータを取得するためにはパラメータが必要

前述の再レンダリングの回数を減らした例でも挙げた通り、atom では単純にその状態を呼べば良かった状況から、 atomFamily に変更したことによりパラメータを指定する必要が出てきました。つまり別の状態として必要なパラメータのコレクションが必要です。そこで JSON に layoutIds という配列を追加し、 Recoil では atom としてその ID を管理することにしました。

const layoutIdsState = atom<string[]>({
  key: 'LayoutIds',
  default: [],
})

これにより、たとえばレイアウトを一覧で表示するような機能を layoutIdslayouts を使って実現できます。 atom の時に比べて一手間必要になりましたが、仕方ないと割り切りたいところです。

atomFamily の default に selector を設定できない

Recoil には selector という関数も提供されています。 atom の値を使って加工した値を返すことができるので、わざわざ atom を呼び出して処理せずとも selector を介してデータの取得や更新などが可能です。また、atom の default として selector を設定することもできるのですが、これがとても優秀だったりします。具体的に、たとえば他の状態で管理しているリクエストに適用したい options とともに /api/layouts にリクエストを送りそのレスポンスを layoutsState の default にセットしているコードが以下の通りです。

const layoutsState = atom<Record<string, Layout>>({
  key: 'Layouts',
  default: selector({
    key: 'Layouts/Default',
    get: async ({ get }) => {
      const options = get(optionsState)
      const response = await fetch('/api/layouts', options)
      const data = await response.json()
      return data.layouts
    },
  }),
})

layoutsState の初期化のタイミングで default の値がセットされるので、サーバへのリクエストは 1 回だけになり、非常にありがたいです。

ここで課題となるのが、atomFamily には selector は使えないということです (参考) 。atomFamily はパラメータを指定する必要があり default の値を決定するためにもパラメータが必要です。したがって selector ではなくパラメータを指定した selector の役割ができる selectorFamily を使う必要があります。しかし今回のケースでは、機能に必要なデータを一括でサーバサイドから JSON 形式で取得するので、デフォルト値をセットしたい atomFamily のパラメータ (たとえばレイアウト ID: 1) とデータ (たとえばレイアウト ID: 1 に相当するレイアウト) を同時に取得することになります。取得したパラメータを抽出し、それに相当するデータを atomFamily の default にセットするという処理を行いたいですが、そう言った方法は予めパラメータを必要とする selectorFamily ではできません。つまり selector は使えず selectorFamily による default の決定もできないので別の方法を考える必要があります。

機能の利用の度にリクエストを送りそのレスポンスを atomFamily にセットするという方法もありますが、これではリクエスト数が多くなってしまいます。本機能はあるアイコンをクリックすることで起動するのですが、頻繁にクリックされる可能性もあり、起動の度にリクエストを送るとなるとサーバへの負荷が高くなってしまいます。そのため、レスポンスをそのまま格納する selector を作ることにしました。

const dataState = selector({
  key: 'Data',
  get: async ({ get }) => {
    const options = get(optionsState)
    const response = await fetch('/api/layouts', options)
    const data = await response.json()
    return data
  },
})

この値を使いそれぞれの atomFamily の default にセットする様にしました。

const layoutsState = atomFamily<Layout, string>({
  key: 'Layouts',
  default: selectorFamily({
    key: 'Layouts/Default',
    get:
      (id) =>
      ({ get }) =>
        get(dataState).layouts[id],
  }),
})
const roomsState = atomFamily<Room, string>({
  key: 'Rooms',
  default: selectorFamily({
    key: 'Rooms/Default',
    get:
      (id) =>
      ({ get }) =>
        get(dataState).rooms[id],
  }),
})
const furnitureItemsState = atomFamily<FurnitureItem, string>({
  key: 'FurnitureItems',
  default: selectorFamily({
    key: 'FurnitureItems/Default',
    get:
      (id) =>
      ({ get }) =>
        get(dataState).furnitureItems[id],
  }),
})

サーバサイドへのリクエストについては、selector の結果はキャッシュされるという特性 (公式ドキュメント) のため初めに dataState の値を取得する時に限定されます。そのため任意の atomFamily で get(dataState) を取得する際に、まだ結果がなければサーバーサイドのレスポンスを取得した後の結果を、キャッシュされた結果があればそれを取得するという処理になります。これで selectorFamily によるそれぞれの初期値の決定ができました。

補足として先ほど atom として設定した layoutIdsState も下記の様に書き直すことができます。こちらは atom なので selector を利用しています。

const layoutIdsState = atom<string[]>({
  key: 'LayoutIds',
  default: selector({
    key: 'LayoutIds/Default',
    get: ({ get }) => get(dataState).layoutIds,
  }),
})

課題に対応した結果

結果的に変数が 3 から 5 に増えてしまいました。これらが増えることはすなわち管理対象が増えるということなので良いことではありません。

リファクタリング前の状態一覧 (atom を使った実装の場合)
Name State
layoutsState atom
roomsState atom
furnitureItemsState atom
リファクタリング後の状態一覧 (atomFamily を使った実装の場合)
Name State
dataState selector
layoutIdsState atom
layoutsState atomFamily
roomsState atomFamily
furnitureItemsState atomFamily

ただし、データ構造、機能要件によって目指すべき状態管理はそれぞれ異なります。今回の様なケースでは atomFamily による状態管理の方が、保守性や可読性が高くパフォーマンスにも良いコードになったと思っています。一概に良い・悪いではなく何事もトレードオフですので、状態管理の方法を選ぶ際には様々な観点から検討し、最適だと思うものを選び実装を行う必要があると感じました。

まとめ

データ構造と機能要件を踏まえて atom から atomFamily に変更し、結果的に状態の数が増えてしまったものの、無駄な処理を省けたり、再レンダリングを抑えたりなど保守性や可読性が高くパフォーマンスにも良いコードにリファクタリングできたかと思います。他にも機能要件はありそのための atom や selector もまだまだありますが、この様な状態管理に変更したからこそ Recoil の コアコンセプト にもある data-flow graph が描きやすくなり、新しい機能追加や保守が容易になったと感じています。

最後に

今回の例はあくまで簡易版で、他の様々な機能要件のために atom の effect や useRecoilCallback などの Recoil の機能も活用しています。また、はじめに紹介した通り 3D の機能もあります。3D は BabylonJS で表現しており BabylonJS 特有のアニメーションと React のライフサイクルを併せて考えながら状態管理も行う必要があるので、難しくもあり非常におもしろい開発だと思います。React x Recoil x BabylonJS での開発が面白そうと思ったあなたは一度弊社の話を聞いてみませんか?

スペースリーでは開発メンバーを募集中です! 詳しくは採用サイトをご覧ください!