🕳️

ReactでUI構造が変わっても状態を保持したい - React-Reverse-Portal -

2025/01/16に公開

こちらは「medicalforce New Year's Blog 2025」8日目の記事です。

今回はUI構造が変わっても状態を保持したいときに便利なReact-Reverse-Portalというライブラリを紹介しようと思います。

解決する課題

例えば、タブレット向けの画面実装で横向きのときは2-column型のレイアウトで表示して縦向きのときはタブを使ったレイアウトで表示したいということがあったとします。
Reactのコードで表現するとおおよそ以下のような実装になるかと思います。

export default Page = () => {
    // 中略
    return isLandscape ? (
        <Grid cols={2}>
            <GridItem>
                <Content1 />
            </GridItem>
            <GridItem>
                <Content2 />
            </GridItem>
        </Grid>
    ) : (
        <div>
            <Tabs>
                <Tab>タブ1</Tab>
                <Tab>タブ2</Tab>
            </Tabs>
            <TabPanel>
                <Content1 />
            </TabPanel>
            <TabPanel>
                <Content2 />
            </TabPanel>
        </div>
    )
}

このように実装した場合、画面の向きが変わるたびにContent1Content2内の状態(例えば、フォーム入力の内容など)はリセットされ、ユーザーが操作していた内容が失われてしまいます。
これはReactのstateはコンポーネントがレンダリングされているUIツリー上の位置と結びついているからです。
画面の向きを変えると状態が失われてしまうのはUXが悪いため対策を考えたいです。

状態がリセットされてしまうことの対策

方法1: stateをリフトアップする

常にレンダリングされる上位のコンポーネントでstateを定義し、propsで渡すという方法です。
一番シンプルで分かりやすい解決策ではありますが、下位のコンポーネント内の状態に関する知識が上位のコンポーネントに共有されてしまいます。
また、状態が変わったときの再レンダリング範囲が広くなりパフォーマンスに悪影響がある可能性もあります。

方法2: 状態管理ライブラリなど別の情報源を利用する

状態をコンポーネントツリー外部に切り離し、jotaiなどの状態管理ライブラリやlocalStorageなど他の情報源を使用して管理する方法です。このアプローチではリフトアップのデメリットを軽減できますが、グローバルな状態が存在することで管理が複雑になってしまいがちというデメリットがあります。

React-Reverse-Portalを使った解決策

React-Reverse-Portalというライブラリを使うと、状態を失わないままコンポーネントのレンダリングされる位置を移動することができます。
このライブラリではInPortal内に配置したコンポーネントをOutPortalに描画し、これらを事前に生成したportalNodeで接続します
これを使用することで、今まで見てきた方法のデメリットを解消しつつ状態保持を実現できます。
以下は、react-reverse-portalを使った実装例です。

import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';

const Page = () => {
    // 中略
    const portalNode1 = useMemo(() => createHtmlPortalNode(), []);
    const portalNode2 = useMemo(() => createHtmlPortalNode(), []);

    return (
        <>
            <InPortal node={portalNode1}>
                <Content1 />
            </InPortal>
            <InPortal node={portalNode2}>
                <Content2 />
            </InPortal>
            {isLandscape ? (
                <Grid cols={2}>
                    <GridItem>
                        <OutPortal node={portalNode1} />
                    </GridItem>
                    <GridItem>
                        <OutPortal node={portalNode2} />
                    </GridItem>
                </Grid>
            ) : (
                <div>
                    <Tabs>
                        <Tab>タブ1</Tab>
                        <Tab>タブ2</Tab>
                    </Tabs>
                    <TabPanel>
                        <OutPortal node={portalNode1} />
                    </TabPanel>
                    <TabPanel>
                        <OutPortal node={portalNode2} />
                    </TabPanel>
                </div>
            )}
        </>
    );
};

React-Reverse-Portalの仕組み

せっかくなので内部実装についても簡単に触れようと思います
まず、createHtmlPortalNodeでdiv要素が作成されます。
ここで作成されたdiv要素の中に、InPortalの内容と同等のものがをReactのportalcloneElementを使用して描画されます。
https://github.com/httptoolkit/react-reverse-portal/blob/master/src/index.tsx#L173-L179
そして、OutPortalでは描画時にそのdiv要素でplaceholder用のdiv要素を置換することでInPortalの内容が表示されるようになっています。
https://github.com/httptoolkit/react-reverse-portal/blob/master/src/index.tsx#L109-L112
このdiv要素はReactによって作成されたものではないため、portalNodeさえ保持していればレンダリングされる位置が変わったとしても内容は失われません。

まとめ

React-Reverse-Portalは、コンポーネントのレンダリング位置を動的に変更しても状態を保持できる便利なライブラリです。本記事ではタブレット向けのUI実装例を通じて、動的なレイアウト変更における状態保持の課題と解決策を解説しました。
Reactで動的なレイアウト変更を伴うアプリケーションを開発する際には、React-Reverse-Portalを選択肢の一つとして検討してみるといいかもしれません。

追記: 注意点

  1. 描画される要素はdiv要素でラップされます
    placeholderとしてdiv要素を使用しているためです

  2. トリッキーな動作ではあるので乱用はしないようにしましょう
    Reactをハックしたような実装なため、予期せぬ動作をする可能性があります
    使用するとしても程々にしましょう

Discussion