💩

Recoilで多数のアイテムを管理するNot badなアイデア

2021/02/26に公開

個人的にRecoilのドキュメントが読みづらいというか、Recoilの状態管理をどのように自分のサービスに落とし込むのかを考えた場合に自分の想定と結果が噛み合わないことが多々発生してしまうと感じています。

特に単一の状態管理よりも複数の状態(アイテム)を管理するときに苦戦をしたので、Tipsとして残します。

環境及び、機能要件

今回はユーザーが複数のアイテムを作成、編集、閲覧できるサービスを想定し、Itemというinterfaceを定義します。

interface Item {
   name: string
   description: string
   isPublished: string
}

今回開発する機能はサーバーからItemの配列を受け取って一覧画面に表示、編集画面で個々のアイテムを編集、削除することができるようにRecoilのState(Store)を定義することです。

Badなアイデア

配列のItemを持つatomを定義する

export const itemState = atom<Item[]>({
  key: "items",
  default: [],
});
//or
export const itemsStateWithType = atomFamily<Item[], {type: ItemType}>({
  key: "itemsWithType",
  default: (type) => [],
})

Recoilのチュートリアルと同じ実装です。しかしながらobjectの配列は非常に使い勝手が悪いと感じます。

編集したデータを更新するために現在の配列から編集後の配列を作成しステートを更新しなければならず、配列を生成する処理を実装する必要があります。加えて、配列を更新しているので単一のアイテムを更新するだけでその他のアイテムも更新したことになるのでパフォーマンスの観点からもあまり良くないと考えられます。

function replaceItemAtIndex(arr, index, newValue) {
  return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)];
}

データを格納するだけのストアとして利用するのであれば特に問題ありませんが、それではRecoilを使うメリットが感じられません。

インデックスをkeyとしたatomFamilyを定義する

export const itemState = atomFamily<Item, number>({
  key: "items",
  default: undefined,
});
//or
export const itemsStateWithType = atomFamily<Item, { index: number, type: Type }>({
  key: "itemsWithType",
  default: undefined,
})

ぱっと見良さそうな実装です。
アイテムの更新もこのように書くことができます。

const index = 0
const item = useRecoilValue(itemState(index))
const submit = useRecoilCallback(({set})=>(name: string)=>{
    set(itemState(index),(oldValue)=>({oldValue, name}))
}, [index]) 

しかしながら欠点として特定のアイテムを削除した際にindexの移動を行う必要があります。
例えば20個目のアイテムを削除する際ただuseResetRecoilState(itemState(20))を呼ぶだけでは21個目が20個目に移動するわけではなく、20個目がundefinedになり穴が空いてしまいます。

先程の配列を入れる時のように全体を更新する必要はなくなりますが、indexの移動を行う実装が必要になるため良い方法とは言えません。

Not badなアイデア

indexを管理するatomとItemを管理するatomFamilyを定義する

export const itemIDsState = atom<string[]>({
  key: "itemIDs",
  default: [],
});

export const itemState = atomFamily<Item, { id: string }>({
  key: "item",
  default: undefined,
});

現状一番工数をかけずに要件を満たす実装ができる構成だと思います。

アイテムの更新はこのように書くことができます。

const id = "xxxxxxxx"
const item = useRecoilValue(itemState({id}))
const submit = useRecoilCallback(({set})=>(name: string)=>{
    set(itemState({id}),(oldValue)=>({ ...oldValue, name }))
}, [id]) 

Indexの更新はitemIDsStateを並び替え、編集することで実現できます。

Itemの配列を使って何かしたいと言う場合selectorを活用することで簡単に利用できます。

export const itemsSelector = selector<
  (Item & { id: string })[]
>({
  key: "items",
  get: ({ get }) => {
    const itemIDs = get(itemIDsState);
    return itemIDs.map((id) => {
      const item = get(itemState({ id }));
      return { ...item, id };
    });
  },
  set: ({ set }, newValue) => {
    if (newValue instanceof DefaultValue) {
      set(itemIDsState, []);
    } else {
      set(
        itemIDsState,
        newValue.map((item) => item.id)
      );
      newValue.forEach((item) => {
        set(itemState({ id: item.id }), { ...item });
      });
    }
  },
});

selectorがatomに依存しているのでアイテムの更新があるとselectorに伝わり最新の状態を取得することができます。

const items = useRecoilValue(itemsSelector)

加えて、インデックスとItemの管理を分離したことで、複数の配列を管理する際に同じItemを利用することができるようになります。

export const itemIDsState = atom<string[], { type: ('main' | 'other') }>({
  key: "itemIDs",
  default: [],
});

export const itemsSelector = selectorFamily<
  (Item & { id: string })[], { type: ('main' | 'other') }
>({
  key: "items",
  get: ({ type }) => ({ get }) => {
    const itemIDs = get(itemIDsState({ type }));
    return itemIDs.map((id) => {
      const item = get(itemState({ id }));
      return { ...item, id };
    });
  },
});

ただこちらの方法も完璧なわけではなく、デメリットとしてインデックスとItemの管理をそれぞれ分けたので、Itemを削除する際にどちらかを初期化し忘れることが発生してしまうなどの問題が起こりうるようになります。

まとめ

今回なぜNot badなアイデアと記載したかと言うと正直この実装が本当に良いものなのか決めかねると思いNot badなアイデアとさせていただきました。Recoil自体触って間もないのでスタンダードな実装方法が自分の中で確立しておらず手探りながら進めている状況です。もっと良いアイデアなどありましたら教えていただけると非常に助かります。

Discussion