我が名はなんとか菜である!

主に技術系の記事を書きますが、ポエムも混入します。

【React】子要素によって親要素の状態を更新するコンポーネントの作り方

4月も半ばに近づきようやく春の陽気を感じられるようになってきましたね。

今日は珈琲館で書いてる。

前提

子要素によって親要素の状態を更新するコンポーネント とは、「そのコンポーネントを特定のコンポーネントchildren として渡すことで特別な処理を行うコンポーネント」のことです。

よく分からないと思うので以下に疑似コードを示します。

import { Tab, TabItem } from './my-handmade-tab';

const Component = () => {
  // 以下のようにして使うと、 "fisrt" と "second" というアイテムを持ったタブが生成される
  // タブがクリックされると中身が切り替わる
  return <Tab defaultKey="first">
     <TabItem tabKey="first" title="first">
       first tab content
     </TabItem>
     <TabItem tabKey="second" title="second">
       second tab content
     </TabItem>
  </Tab>
}

みたいなやつのことです。UIライブラリとかでよく見るやつですね。

実は最近まで「こういうのってどうやるといいのかなぁ」とは思いつつも、これを実現しているライブラリの中身を見に行くでもなくなんとなくモヤモヤした日々を送っていたところ、似たような処理を自前で実装する必要がとうとう出てきてしまい、ひええ~と思いながら頑張って考えた結果なんとか実現できたので、その成果を書いていこうと思います。

結論: Context を使う

Context API を使い、親で生成した state を Context を経由して子に渡し、子は渡された setState 等を用いて自分の状態を親に伝えます。

以下、コードを示します。 ここでは、例として先にも挙げた Tab コンポーネントを作ってみました。

import {
  createContext,
  memo,
  PropsWithChildren,
  useCallback,
  useContext,
  useLayoutEffect,
  useMemo,
  useState,
  VFC,
} from "react";

import "./tab.css";

type TabState = {
  activeKey: string;
  addItem: (title: string, key: string) => void;
};

type TabValue = {
  title: string;
  key: string;
};

const TabContext = createContext<TabState>({
  activeKey: "",
  addItem: () => {},
});

type TabProps = {
  defaultKey: string;
};

export const Tab: VFC<PropsWithChildren<TabProps>> = ({
  defaultKey,
  children,
}) => {
  const [activeKey, setActiveKey] = useState(defaultKey);
  const [tabs, setTabs] = useState<TabValue[]>([]);
  const addTab = useCallback((title: string, key: string) => {
    setTabs((tabs) => {
      if (tabs.findIndex((item) => item.key === key) > 0) {
        return tabs;
      } else {
        return [...tabs, { title, key }];
      }
    });
  }, []);

  const state = useMemo<TabState>(
    () => ({
      activeKey,
      addItem: addTab,
    }),
    [activeKey, tabs]
  );

  return (
    <TabContext.Provider value={state}>
      <div className="tab-wrap">
        {tabs.map(({ title, key }) => (
          <div
            key={key}
            className={`tab-item ${activeKey === key ? "active" : ""}`}
            onClick={() => setActiveKey(key)}
          >
            {title}
          </div>
        ))}
      </div>
      {children}
    </TabContext.Provider>
  );
};

type TabItemProps = {
  tabKey: string;
  title: string;
};

export const TabItem: VFC<PropsWithChildren<TabItemProps>> = ({
  title,
  tabKey,
  children,
}) => {
  const { activeKey, addItem } = useContext(TabContext);

  useLayoutEffect(() => {
    addItem(title, tabKey);
  }, []);

  return tabKey === activeKey ? <>{children}</> : null;
};

これはこのように使います。

const Main = () => {
  return <Tab defaultKey="first">
    <TabItem tabKey="first" title="first">
      first content
    </TabItem>
    <TabItem tabKey="second" title="second">
      second content
    </TabItem>
  </Tab>
}

実行結果

初期状態f:id:happo31:20220409181242p:plain

second をクリックした状態 f:id:happo31:20220409181158p:plain

出来ていますね。便利。

バーっと示してハイ終わりだとアレなので、ポイントを解説していきます。

Context 経由で state 更新用関数を子コンポーネントに渡す

この部分です。

...
const addTab = useCallback((title: string, key: string) => {
  setTabs((tabs) => {
    if (tabs.findIndex((item) => item.key === key) > 0) {
      return tabs;
    } else {
      return [...tabs, { title, key }];
    }
  });
}, []);

const state = useMemo<TabState>(
  () => ({
    activeKey,
    addItem: addTab,
  }),
  [activeKey, tabs]
);
...

基本的に、props から渡されてくる children の中身を見て云々のようなことは出来ないようになっているので、親側で管理するステート(この例の場合はTabの要素)を子の側で更新してもらうためにこのように更新用関数を Context に詰めます。

(非公開APIとか使えば出来るのかもしれないけど少なくとも TypeScript の型が付いている範囲ではそのような操作が出来る関数はない)

ここ、単に useState で返される setter をそのまま渡してもいいのですが、敢えてそうせずに 操作を明確にした関数 を渡すようにしています。こういう親子間のステート共有は複雑になりやすいので、なるべくこういう風に操作を限定するようにしたほうがメンテナンス性が高まると思われます。(あんま変わらんかもしれんのでこれはまあどちらかというと僕の趣味です)

親側で管理している state を子側で使う

useContext している部分ですね。

export const TabItem: VFC<PropsWithChildren<TabItemProps>> = ({
  title,
  tabKey,
  children,
}) => {
  const { activeKey, addItem } = useContext(TabContext);

  useLayoutEffect(() => {
    addItem(title, tabKey);
  }, []);

  return tabKey === activeKey ? <>{children}</> : null;
};

ここでやっているのは、初期化時に自分の title を親に伝えることと、現在選択されているタブが自分に渡されている tabKey と一致していたら children を描画するということです。

ここのキモは useLayoutEffect を使っていることで、 useEffect を使うと子要素の初期化が終わる前に親要素のレンダリングが行われ、一瞬だけ親要素が空の状態が表示されてしまいます。

しかも初期化終了後、要素が描画された状態へと変わるためガクっと見えてしまうことになります。とても格好悪いですし、UXの観点からもよくありません。

それを防ぐために、useLayoutEffect を使って描画前に子要素の初期化を終わらせるようにしています。

useEffectuseLayoutEffect はどちらを使えばいいのか迷いがちですが、初期化が終了した状態だけがユーザーに見えて欲しいような場合は useLayoutEffect の出番、ということになると思います。

まとめ

このような書き方を使えば使う側がすっきり書けるようなコンポーネントというのは、割とあると思います。

例えばテーブル要素なんかは children に列を表すコンポーネントを書いていけばヘッダーに自動的に列の名前が出てくる、みたいな感じで書けるととても直感的になるので、使う側としては非常に便利です。

Context というと深くネストされたコンポーネント間での state の共有という役割に目がいきがちですが、こういったトリッキーな使い方をすることでちょっと不思議なコンポーネントを作ったりもできるので、奥深いなと感じます。

このようなデザインパターンになっているコンポーネントは直観だと割と不思議な感じがしていましたが、実装してみたら意外と単純な作りになっていたのでとても勉強になりました。

ただ、こういうパターンは「propsを見ればなんとなく使い方が分かる」シンプルさを犠牲しており使う側が使い方や依存関係を覚える必要が出てくるため、あまり乱用するとむしろ使いづらくなってしまうかもしれません。

よって、タブやテーブルのような、使う側から見ても依存関係が明確な機能に対して使うのが良いのかなと思います。

おまけ

今回作ったコードは以下にあります。 github.com

おまけのおまけ

今回作ったタブでは、以下のように書くと「どのタブでも常に表示されている要素」を実現できます。

<Tab defaultKey="first">
  <h3>common content</h3> // ★
  <TabItem tabKey="first" title="first">
    first content
  </TabItem>
  <TabItem tabKey="second" title="second">
    second content
  </TabItem>
</Tab>

実行結果

初期状態 f:id:happo31:20220409182745p:plain

second をクリックした状態 f:id:happo31:20220409182831p:plain

// ★ で示した部分のコンテンツが常に表示されているのが分かると思います。

なぜこうなるかというと、中身の表示/非表示の判断が子要素の中で行われているためです。

大本の Tab に手を加えることなくこういったことも出来るので、応用例はもっとあるように思えてきますね。