Yuhei Yasuda

ReactのuseStateをrender propとして抽象化して、コンポーネントを分割せずにコードのまとまりをよくする

ReactのuseStateを使用する際、stateのスコープが必要以上に大きくなってしまうことがある。たとえば次の例のように、コンポーネントの中のごく限られた部分でしか使わないstateが、コンポーネントのどこからでも参照できてしまう。

function App() {
	const [isFirstOpen, setFirstOpen] = useState(false);
	const [isSecondOpen, setSecondOpen] = useState(false);
	const [isThirdOpen, setThirdOpen] = useState(false);

	return (
		<>
			<Button onClick={() => setFirstOpen(true)}>First</Button>
			<Popover isOpen={isFirstOpen} onOpenChange={setFirstOpen}>
				...
			</Popover>

			<Button onClick={() => setSecondOpen(true)}>Second</Button>
			<Popover isOpen={isSecondOpen} onOpenChange={setSecondOpen}>
				...
			</Popover>

			<Button onClick={() => setThirdOpen(true)}>Third</Button>
			<Popover isOpen={isThirdOpen} onOpenChange={setThirdOpen}>
				...
			</Popover>
		</>
	);
}

こうした場合の一般的な解決策としては、それぞれのstateに応じた粒度でコンポーネントを分割することだ。次のようにすると、stateのスコープが必要最小限になるように制限できる。

function App() {
	return (
		<>
			<First />
			<Second />
			<Third />
		</>
	);
}

function First() {
	const [isOpen, setOpen] = useState(false);

	return (
		<>
			<Button onClick={() => setOpen(true)}>First</Button>
			<Popover isOpen={isOpen} onOpenChange={setOpen}>
				...
			</Popover>
		</>
	);
}

function Second() {
	const [isOpen, setOpen] = useState(false);

	return (
		<>
			<Button onClick={() => setOpen(true)}>Second</Button>
			<Popover isOpen={isOpen} onOpenChange={setOpen}>
				...
			</Popover>
		</>
	);
}

function Third() {
	const [isOpen, setOpen] = useState(false);

	return (
		<>
			<Button onClick={() => setOpen(true)}>Third</Button>
			<Popover isOpen={isOpen} onOpenChange={setOpen}>
				...
			</Popover>
		</>
	);
}

しかし場合によっては、stateの粒度に基づいてコンポーネントを分割していくと、却ってソースコードの見通しが悪くなることがある。

そこで一つの代替案として、IIFEの中でuseStateを使用するというやり方がある。次のようにすることで、useStateのスコープを制限できる。

function App() {
	return (
		<>
			{(() => {
				const [isOpen, setOpen] = useState(false);
				return (
					<>
						<Button onClick={() => setOpen(true)}>First</Button>
						<Popover isOpen={isOpen} onOpenChange={setOpen}>
							...
						</Popover>
					</>
				);
			})()}

			{(() => {
				const [isOpen, setOpen] = useState(false);
				return (
					<>
						<Button onClick={() => setOpen(true)}>Second</Button>
						<Popover isOpen={isOpen} onOpenChange={setOpen}>
							...
						</Popover>
					</>
				);
			})()}

			{(() => {
				const [isOpen, setOpen] = useState(false);
				return (
					<>
						<Button onClick={() => setOpen(true)}>Third</Button>
						<Popover isOpen={isOpen} onOpenChange={setOpen}>
							...
						</Popover>
					</>
				);
			})()}
		</>
	);
}

しかしこれでは、条件に応じてそのIIFEの表示を切り替えるようなケースに対応できなくなってしまう。たとえば次のような実装をすると、isThirdVisibleの値が切り替わった時にフックの整合性が保てなくなってしまう。

function App({ isThirdVisible }: { isThirdVisible: boolean }) {
	return (
		<>
			{(() => {
				const [isOpen, setOpen] = useState(false);
				return (
					<>
						<Button onClick={() => setOpen(true)}>First</Button>
						<Popover isOpen={isOpen} onOpenChange={setOpen}>
							...
						</Popover>
					</>
				);
			})()}

			{(() => {
				const [isOpen, setOpen] = useState(false);
				return (
					<>
						<Button onClick={() => setOpen(true)}>Second</Button>
						<Popover isOpen={isOpen} onOpenChange={setOpen}>
							...
						</Popover>
					</>
				);
			})()}

			{isThirdVisible &&
				(() => {
					const [isOpen, setOpen] = useState(false);
					return (
						<>
							<Button onClick={() => setOpen(true)}>Third</Button>
							<Popover isOpen={isOpen} onOpenChange={setOpen}>
								...
							</Popover>
						</>
					);
				})()}
		</>
	);
}

加えて、最上位レベルのフックのみを呼び出すというルールに反しているため、ESLintプラグインのeslint-plugin-react-hooksを使用すると警告が発生してしまう。あくまでコードスタイルに限っての問題ではあるが。

したがって、IIFEの使用は得策ではなさそうだ。そのためやはり、useStateのスコープを制限するためにはコンポーネントを分割しなければならないという制約は免れない。

そこで、useStateの使用を抽象化する汎用的なコンポーネントを作成するとする。これを用いることで、stateのスコープを制限しつつも、ノードの記述はインライン化できるようになる。

import { State } from "./State";

function App() {
	return (
		<>
			<State initial={false}>
				{({ state: isOpen, setState: setOpen }) => (
					<>
						<Button onClick={() => setOpen(true)}>First</Button>
						<Popover isOpen={isOpen} onOpenChange={setOpen}>
							...
						</Popover>
					</>
				)}
			</State>

			<State initial={false}>
				{({ state: isOpen, setState: setOpen }) => (
					<>
						<Button onClick={() => setOpen(true)}>Second</Button>
						<Popover isOpen={isOpen} onOpenChange={setOpen}>
							...
						</Popover>
					</>
				)}
			</State>

			<State initial={false}>
				{({ state: isOpen, setState: setOpen }) => (
					<>
						<Button onClick={() => setOpen(true)}>Third</Button>
						<Popover isOpen={isOpen} onOpenChange={setOpen}>
							...
						</Popover>
					</>
				)}
			</State>
		</>
	);
}

<State>の実装は次の通り。

import type {
	Dispatch,
	DispatchWithoutAction,
	ReactNode,
	SetStateAction,
} from "react";
import { useCallback, useState } from "react";

/**
 * The implement a hooks version of React PowerPlug’s `<State>`.
 * https://renatorib.github.io/react-powerplug/#/docs-components-state
 *
 * @example
 * <State initial={false}>
 *   {({ state: isOpen, setState: setOpen }) => (
 *     <>
 *       <button type="button" onClick={() => setOpen(true)}>Open</button>
 *       <Popover isOpen={isOpen} onOpenChange={setOpen}>...</Popover>
 *     </>
 *   )}
 * </State>
 */
export function State<S>({
	initial: initialState,
	children,
}: {
	initial: S | (() => S);
	children: (props: {
		state: S;
		setState: Dispatch<SetStateAction<S>>;
		resetState: DispatchWithoutAction;
	}) => ReactNode;
}) {
	const [state, setState] = useState(initialState);
	const resetState = useCallback(() => setState(initialState), [initialState]);

	return children({
		state,
		setState,
		resetState,
	});
}

<State>はその内部でuseStateを保持しつつ、render propを介してその値や操作方法を提供する。これによって、スコープは<State>childrenだけに制限できるようになる。最初の例と比較して、コードのまとまりはよくなったように思う。

この<State>は僕が考案したものではなく、React PowerPlugというライブラリからアイデアを借用したものだ。残念ながら、React PowerPlugは長らくメンテナンスされておらず、フックスやTypeScriptにも対応していないので、僕は前述のように自分で実装し直して使っている。

もっとも、昨今の気の利いたUIライブラリであれば、こうした冗長なコードを省略できる仕組みもある。たとえば、Headless UIの系譜にあるReact Aria Componentsでは、次のようにコンポーネントを構成するだけで、状態管理やイベントの登録が暗黙的に行われるようになっている。

<DialogTrigger>
	<Button>Open popover</Button>
	<Popover>
		<OverlayArrow>
			<svg width={12} height={12}>
				<path d="M0 0,L6 6,L12 0" />
			</svg>
		</OverlayArrow>
		<Dialog>This is a popover.</Dialog>
	</Popover>
</DialogTrigger>
Popover – React Aria

こうした仕組みによって、多くの場面には対応できるが、これでは解決できないようなケースではStateコンポーネントを採用するとよいだろう。


ついでに言えば、<Suspense>を使う場合にも同じ問題がある。これに対して、React Routerでは、<State>と同じくrender propインターフェースが実装された<Await>という独自コンポーネントを提供することで、やはりコンポーネントを分割せずに記述できる仕組みを実現している。

Deferred Data v6.15.0 | React Router