🍽️

状態管理を「まとめる」・「バラす」

に公開

一人暮らしで自炊に悩んでたとき、昔買ったレシピ本に「ひき肉はまとめてもバラしても使える万能食材」と書いてありました。しかしなんとこれは状態管理でも同じことです。

適切なスコープ

状態管理を実装するときは適切なスコープであるかが重要です。気が付かずに手を動かしていると、広い範囲の状態を狭い場所から操作していたり、逆に狭い範囲の状態を広い範囲から操作していたりすることが起こり得ます。

それは「状態管理」というドメインの抽象化に失敗した状態であり、放置するとアプリケーションの操作フローがどうなっているかを読み取るのが次第に難しくなります。解決策はここ以降に示すように、状態管理の範囲の大小をいつでも変えられるようにしてしておくことです。

サンプル

  • 今回のサンプルは、始めに実装した状態管理を小さくバラしていく手順を解説します。
  • 題材は開閉できるアコーディオンを取り上げます。 (任意の)ライブラリ使えばいいじゃん!と思った方は回れ右です
  • a11yは省略します。

Step 1

まずはアコーディオンの一覧を実装してみます。ReactとTypeScriptは「最低限書ける」レベルのメンバーが非常に書きやすいであろうパターンが次のコードです。

swap-value.ts
/**
 * 配列`array`に値`searchElement`があれば削除し、なければ追加する。
 */
function swapValue<T>(array: T[], searchElement: T): T[] {
  if (array.includes(searchElement)) {
    return array.filter((value) => value !== searchElement);
  } else {
    return [...array, searchElement];
  }
}
const items = [
  { key: "1", title: "Title 1", content: "Content 1" },
  { key: "2", title: "Title 2", content: "Content 2" },
  { key: "3", title: "Title 3", content: "Content 3" },
];

function List() {
  const [openedKeys, setOpenedKeys] = useState<string[]>([]);

  const handleItemClick = (selectedKey: string) => {
    setOpenedKeys((o) => swapValue(o, selectedKey));
  };

  return (
    <div>
      {items.map((item) => (
        <div key={item.key}>
          <button type="button" onClick={() => handleItemClick(item.key)}>
            {item.title}
          </button>
          {openedKeys.includes(item.key) && <div>{item.content}</div>}
        </div>
      ))}
    </div>
  );
}

ここにどんな問題点があるのでしょうか。状態管理の処理をじっくり把握してみると...

  • アコーディオンの開閉は、Listコンポーネントで状態を持っています
  • アコーディオンの開閉は、アコーディオンの外側には漏れ出ていません

この2つの性質は矛盾しており、本来は適切ではありません。開閉の状態もmapしたコンポーネントの内側に入ることが相応しいです。

逆も然りです!例えば開いているアコーディオンの総数をどこかに表示するとなればこのスコープが適切です。「状態はどこの範囲にあるのか?」を判断する作業が重要なことです。

上記のコードは単純な例に過ぎないので我慢できると思われるかもしれませんが、実際のアプリケーションの例としては複雑な業務用のテーブルUI・ブログやソーシャルメディア等に見られる入れ子構造のコメントリスト等を想像してください。

それでは次のStep2〜4で解決策を提示していきます。

Step 2

最もシンプルな対処法は状態管理をそのまま子に移動することです。
基本的にはこれで問題ありません。

+interface ListItemProps {
+  title: ReactNode;
+  content: ReactNode;
+}
+
+function ListItem({ title, content }: ListItemProps) {
+  const [open, setOpen] = useState(false);
+
+  const toggleOpen = () => {
+    setOpen((o) => !o);
+  };
+
+  return (
+    <div>
+      <button type="button" onClick={toggleOpen}>
+        {title}
+      </button>
+      {open && <div>{content}</div>}
+    </div>
+  );
+}

function List() {
  return (
    <div>
      {items.map((item) => (
-        <div key={item.key}>
-          <button type="button" onClick={() => handleItemClick(item.key)}>
-            {item.title}
-          </button>
-          {openedKeys.includes(item.key) && <div>{item.content}</div>}
-        </div>
+        <ListItem key={item.key} title={item.title} content={item.content} />
      ))}
    </div>
  );
}

これで、アコーディオンの状態の一覧に対して何かやるわけでもないのに「アコーディオンのkeyの配列」などというまどろっこしいものを気にする必要はなくなりました。

別件としてこの手のUIは複雑なデータロジックが絡んでることも多々あり、その場合はatomicなViewだけを抜き出したくなってきます。Step3・4に手法を載せます。

Step 3

汎用的にUIを扱えるようにまるっと切り出します。

disclosure.tsx (追加)
disclosure.tsx
function useDisclosure() {
  const [open, setOpen] = useState(false);

  const toggleOpen = () => {
    setOpen((o) => !o);
  };

  return { open, toggleOpen };
}

type UseDisclosureReturn = ReturnType<typeof useDisclosure>;

// ---

const DisclosureContext = createContext<UseDisclosureReturn | null>(null);

function useDisclosureContext() {
  const context = useContext(DisclosureContext);
  if (!context)
    throw new Error(
      `useDisclosureContext must be inside <DisclosureProvider />`
    );
  return context;
}

// ---

type DisclosureProviderProps = {
  children: ReactNode;
};

function DisclosureProvider({ children }: DisclosureProviderProps) {
  const value = useDisclosure();

  return <DisclosureContext value={value}>{children}</DisclosureContext>;
}

// ---

type DisclosureTriggerProps = Omit<ComponentPropsWithRef<"button">, "type">;

function DisclosureTrigger(props: DisclosureTriggerProps) {
  const { onClick, ...rest } = props;
  const context = useDisclosureContext();

  return (
    <button
      type="button"
      onClick={(event) => {
        onClick?.(event);
        context.toggleOpen();
      }}
      {...rest}
    />
  );
}

// ---

type DisclosureContentProps = ComponentPropsWithRef<"div">;

function DisclosureContent(props: DisclosureContentProps) {
  const context = useDisclosureContext();

  return context.open ? <div {...props} /> : null;
}
function List() {
  return (
    <div>
      {items.map((item) => (
-       <ListItem key={item.key} title={item.title} content={item.content} />
+       <DisclosureProvider key={item.key}>
+         <DisclosureTrigger>{item.title}</DisclosureTrigger>
+         <DisclosureContent>{item.content}</DisclosureContent>
+       </DisclosureProvider>
      ))}
    </div>
  );
}

Step 4

もしここで、「開閉状態に合わせてラベルやスタイルを変化させたい」という要望が来たらどうしますか?答えはRender Propsの出番です。

JSXのスコープ内で持っているpropsやstateに対して何かやる」という目的では非常によく使われるパターンです。ただしアプリケーションレイヤーというよりはライブラリレイヤー(?)で頻出でしょう。

<Foo render={arg => ... } />

対照的なものが、「コンポーネントのトップレベルで持っているpropsやstateに対して何かやる」という目的を達成させるためのhooksの呼び出しです。

function Component() {
  const arg = useSomething()

   // ...
}

以下は、開閉状態に合わせて上向き三角形・下向き三角形のアイコンを付ける手順です。

disclosure.tsx
type DisclosureProviderProps = {
-  children: ReactNode;
+  children: ReactNode | ((value: UseDisclosureReturn) => ReactNode);
};

function DisclosureProvider({ children }: DisclosureProviderProps) {
  const value = useDisclosure();

  return (
    <DisclosureContext value={value}>
-      {children}
+      {children instanceof Function ? children(value) : children}
    </DisclosureContext>
  );
}
 <DisclosureProvider>
+  {({ open }) => (
+    <>
        <DisclosureTrigger>
-         {item.title}
+         {item.title} {open ? "▲" : "▼"}
        </DisclosureTrigger>
      <DisclosureContent>{item.content}</DisclosureContent>
+    </>
+  )}
 </DisclosureProvider>

おわりに

モダンなフロントエンドでは、状態管理の設計にいかに敏感になれるかが主要なポイントであると感じています。上記の問題は元々存在しているライブラリで解決できるかもしれませんが、作業工程の考え方を身につけることが大切です。

Discussion