
業務でおきた React の書き方
お仕事では、時として難しい要求に直面することがあります。そのような折には、頭を悩ませながら記憶の片隅にある微かな手がかりを頼りにコードを綴ったり、ドキュメントを端から端まで丹念に読み込んだりするしかありません。それを乗り越えた暁には、何とも言えない達成感を味わえます。
今回は業務でたまたま遭遇した React のパターンについて、まとめてみます。
Children を利用して Server Components に context の値を渡す
Context の情報を元に表示を分岐することがあります。下記のコードは全てが Client Components であれば、plan の情報に応じてチャートの表示を出し分けてくれます。
export const DashboardPage = () => {
const plan = useContext(PlanContext);
return (
<Section>
{plan.personal === true && <PersonalPlanChart />}
{plan.pro === true && <ProPlanChart />}
</Section>
)
}
ただし、Server Components を含む構成になると、このコードをそのまま扱うことはできません。
DashboardPage では useContext を利用しているため、Server Component にできません。
DashboardPage を Client Component にすると、 Chart を Server Components にできません。
つまり、下記のような要件を満たすことが難しくなります。
- PersonalPlanChart と ProPlanChart でデータフェッチを行っており、Server Components にしたい
- Context を利用しており、その情報をもとに Components を出し分けたい
こちらの対処方法があります。このように実装します。
- DashboardPage(Server Component)から Context を利用するための Wrapper(Client Component)を呼び出す
- Wrapper(Client Component)の children として Chart(Server Components)を渡す
- Wrapper(Client Component)で React.Children を利用して分岐処理を行う
言葉では伝えづらいですが、実装コードでご覧いただければ理解できます。
// page.tsx
export const DashboardPage = async ({ ... }: DashboardPageProps) => {
return (
<DashboardClientWrapper>
<Section data-type='personal'>
<PersonalPlanChart />
</Section>
<Section data-type='pro'>
<ProPlanChart />
</Section>
</DashboardClientWrapper>
)
}
// client.tsx
'use client'
export const DashboardClientWrapper = ({ children }: { children: ReactNode }) => {
const plan = useContext(PlanContext);
return (
<Section>
{Children.map(children, (child) => {
if (isValidElement<{ children: ReactElement; 'data-type': string }>(child)) {
const type = child.props['data-type']
if (type === 'personal' && plan.personal === true) return child
if (type === 'pro' && plan.pro === true) return child
}
return null
})}
</Section>
)
}
DashboardPage 側の実装は理解できますね。data-type を与えた Section の内側に、各 Chart コンポーネントを配置しているだけです。
複雑なのは DashboardClientWrapper です。こちらでは以下の処理を行っています。
- useContext でプランの情報を取得する
- React API の Children.map を利用して、children として渡された各要素を操作する
- isValidElement で妥当な ReactElement であることを判定する
- data-type 属性でどのコンポーネントかを判定する
- 4 の情報を元に、Context の値が true の場合に該当する ReactElement を返す
つまり、Client Component から Server Component を直接子要素として配置できない制約を回避をします。そのために Server Component から Client Component を呼び出し、その children として Server Component を渡します。
なお、React の公式ドキュメントにもありますように、Children API の使用は推奨されていません。isValidElement も含めて、これらはレガシー API として扱われており、新しく書くコードでの利用は推奨されていません。あくまでエスケープハッチとして利用してください。
Children の使用は一般的ではなく、コードが壊れやすくなる可能性があります。一般的な代替手段をご覧ください。
https://ja.react.dev/reference/react/Children
推奨案
明示的に props として各コンテンツを渡す方法で、Children API を利用せずに表現できます。
制約がない場合はこちらが推奨されます。
// page.tsx
export const DashboardPage = async ({ ... }: DashboardPageProps) => {
return (
<DashboardClientWrapper
personalContent={<PersonalPlanChart />}
proContent={<ProPlanChart />}
/>
)
}
// client.tsx
'use client'
export const DashboardClientWrapper = ({
personalContent,
proContent
}: {
personalContent: ReactNode;
proContent: ReactNode;
}) => {
const plan = useContext(PlanContext);
return (
<Section>
{plan.personal && personalContent}
{plan.pro && proContent}
</Section>
)
}
この実装方法には以下のメリットがあります。
- 明示的な props により、TypeScript の型チェックが効く
- コードからどのコンテンツが表示されるか明確にわかる
- data 属性や Children API に依存しないため、記述しやすい
useIsSSR でサーバーサイドレンダリングかを判断する
Client Components で下記のように DOM を操作すると、エラーが起きます。
'use client'
export const TableOfContentList = ({
html,
}: {
html: string
}) => {
let headings: Element[] = useMemo(() => [], [])
const dom = new DOMParser().parseFromString(html, 'text/html')
headings = Array.from(dom.querySelectorAll('h2, h3'))
下記のように参照エラーが発生します。つまり、Node.js 環境で実行されていることがわかります。
⨯ ReferenceError: DOMParser is not defined
at ...
26 | })
27 |
> 28 | const dom = new DOMParser().parseFromString(html, 'text/html')
| ^
29 | const h = Array.from(dom.querySelectorAll('h2, h3'))
これは、Next.js では初回レンダリング時にサーバー側でも実行されるためです。その際に Node.js 環境には DOMParser が存在しないことが原因で起こります。
最も簡単な解決方法は、useEffect の中で利用することです。
'use client'
export const TableOfContentList = ({
html,
}: {
html: string
}) => {
const [headings, setHeadings] = useState<Element[]>([])
useEffect(() => {
const dom = new DOMParser().parseFromString(html, 'text/html')
setHeadings(Array.from(dom.querySelectorAll('h2, h3')))
}, [html])
少し冗長ですが、useEffect が発火されたことでマウント済みであるという状態を管理する方が、意味合い的にはわかりやすいこともあります。
'use client'
export const TableOfContentList = ({
html,
}: {
html: string
}) => {
const [mount, setMount] = useState(false)
let headings: Element[] = []
if (mount) {
const dom = new DOMParser().parseFromString(html, 'text/html')
headings = Array.from(dom.querySelectorAll('h2, h3'))
}
useEffect(() => {
setMount(true)
}, [])
コード量が少なく全体像を把握できる段階であればよいですが、不要な Effect はできるだけ減らすのが React のベストプラクティスです。セマンティクス的にも、SSR で実行されていないことを保証する方がより良いはずです。そこで登場するのが useIsSSR Hook です。
私は React Aria パッケージでこの実装を見かけました。公式の説明を引用すると、「ブラウザ固有のレンダリングをハイドレーション後まで遅延させる」ことをするのが useIsSSR です。
Returns whether the component is currently being server side rendered or hydrated on the client. Can be used to delay browser-specific rendering until after hydration.
https://react-spectrum.adobe.com/react-aria/useIsSSR.htm
名前の通り、SSR なら true を返すので、下記のような実装にできます。セマンティクス的にもスッキリして、Effect をコード上から排除できたので見通しが立ちやすくなりました。
'use client'
import { useIsSSR } from 'react-aria'
export const TableOfContentList = ({
html,
}: {
html: string
}) => {
const isSSR = useIsSSR()
let headings: Element[] = []
if (!isSSR) {
const dom = new DOMParser().parseFromString(html, 'text/html')
headings = Array.from(dom.querySelectorAll('h2, h3'))
}
useIsSSR の実装
コードは以下のようになっています。
React 18 以降の部分(context より前の部分)を解説すると、useSyncExternalStore は3つの引数を受け取ります。
- subscribe: ストアにサブスクライブを開始し、また callback 引数を受け取る関数。(省略)
- getSnapshot: コンポーネントが必要とするストアにあるデータのスナップショットを返す関数。(省略)
- 省略可能 getServerSnapshot: ストアのデータの初期スナップショットを返す関数。(省略)
- https://ja.react.dev/reference/react/useSyncExternalStore#parameters
2つ目の引数は、コンポーネントからアクセスされた際に通常呼び出されるストアのデータを取得する関数で、3つ目の引数は、SSR かハイドレーション中にのみ呼び出される関数です。
つまり、2つ目の関数が呼び出される場合は SSR タイミングではなく、3つ目の関数が呼び出されるタイミングのみ SSR と判定できます。これを利用してスナップショットを返す関数で固定のブール値を返しているのが useIsSSR になります。
render hooks
初出としては、おそらくこちらになります。React Custom Hooks で Components を返すという設計になります。
私はカメラで QR コードを撮影した後に、値を取得できるボタンコンポーネントを設計しました。
その際に render hook で行っていることは下記の通りです。
- Hooks を呼び出して button をレンダリングする
- カメラの形をした input に file を追加する
- handleFileChange が実行される
- file を QR コードとしてスキャンする
- 引数として受け取った Zod Schema を元に値を parse する
- parse した値が value として保存される
- Hooks の返り値である value を利用して処理をする
export const useScan = <Schema extends z.ZodType>(
schema: Schema,
) => {
const [loading, setLoading] = useState(false);
const [value, setValue] = useState<z.infer<Schema> | null>(null);
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const [file] = event.target.files ?? [];
const decoded = await decodeQrFromFile(file);
const parsed = schema.parse(JSON.parse(decoded));
setValue(parsed);
};
const button = () => {
return (
<label className="flex cursor-pointer" aria-disabled={loading}>
<input
type="file"
disabled={loading}
accept="image/*"
capture="environment"
className="sr-only"
onChange={handleFileChange}
/>
<Camera className="size-1/2" />
</label>
);
};
return { button, value };
};
愚直に実装すると、ScanButton の実装は handler を受け取る形にすれば使いまわせます。ただし呼び出し元で state の管理とハンドラーを用意します。
// scan-page.tsx
export const ScanPage = () => {
const [value, setValue] = useState()
const [loading, setLoading] = useState(false)
// データの取得からバリデーションとか...
const handler = () => {}
return (
<Section>
<ScanButton handler={handler} loading={loading} />
</Section>
)
}
// scan-button.tsx
interface Props {
handler: () => {}
}
export const ScanButton = ({handler}: Props) => {
return (
<label className="flex cursor-pointer" aria-disabled={loading}>
<input
type="file"
disabled={loading}
accept="image/*"
capture="environment"
className="sr-only"
onChange={handler}
/>
<Camera className="size-1/2" />
</label>
);
}
サーバーから取得した値などを親元から props としてバケツリレーする分には、その props が変化しないので可読性は落ちません。しかし、子が使う state まで親元で管理すると煩雑になってしまいます。
render hooks パターンでは、hooks で状態を管理できるので、実際に state を利用する箇所で管理できる上に、共通処理もまとめ上げることができます。
もし似たような状況になりましたら、ぜひ利用してみてください。
Next.js でモーダルを実現する
これは実装パターンというよりも、私がよく忘れるので備忘録として記述しています。
Next.js でモーダルを実装する場合に、Parallel Routes と Intercepting Routes を組み合わせて利用します。まずはこれらの役割について整理しましょう。
Parallel Routes
Parallel Routes は、Slots を作成して Layout に対して複数の子要素を渡すことができる機能です。
Slots は Route Segments ではないため、URL のルーティングには影響しません。
通常、Layout には children が渡されますが、Parallel Routes を使うことでそれとは別に複数の ReactNode を渡すことができます。
@
プレフィックスを使ってディレクトリを定義します。例えば /app/greeting/@modals
のように定義すると、Layout でその中身を表示できます。
export default function GreetingLayout(
// /greeting/@modals の場合に route は /greeting になる
props: LayoutProps<"/greeting">,
) {
return (
<div>
{props.children}
{props.modals}
</div>
);
}
また、default.tsx を定義すると、Slot の中身が存在しない場合のフォールバックとして表示されます。
// @modals/default.tsx
export default function Default() {
return null;
}
Intercepting Routes
Intercepting Routes は、Soft Navigation(クライアントサイドでのページ遷移)が起こった際に、現在のレイアウトを維持したまま別ページの内容を表示できる機能です。Hard Navigation(直接 URL アクセスやページリロード)の場合は、インターセプトは作用せず、通常のページが表示されます。
以下のディレクトリ構造の場合、/greeting
から /greeting/[code]
への Soft Navigation 時にインターセプトが発生します。
/app/greeting
├── [code]
│ └── page.tsx
├── @modals
│ ├── (.)[code]
│ │ └── page.tsx
│ └── default.tsx
├── layout.tsx
└── page.tsx
この例では、(.)[code]
は同一セグメントを示すため、/greeting
からの遷移がマッチします(@modals
はセグメントに影響を与えないのでその1個上が対象になる)。
もし (..)[code]
とした場合は、1つ上のセグメント、つまり /
からの遷移がマッチすることになります。
Intercepting Routes は (..)
を利用した表記をします。宇宙人の顔みたいな見た目になります。
(.)
は同じレベルのセグメントに一致する(..)
は 1 つ上のレベルのセグメントに一致する(..)(..)
は 2 つ上のレベルのセグメントに一致する(...)
はルートアプリディレクトリのセグメントに一致する
Parallel Routes と Intercepting Routes の組み合わせ
これらの性質を組み合わせることで、ナビゲーションの種類に応じてモーダルを表示する仕組みを実現できます。
Soft Navigation の場合
- 現在のレイアウトを維持したまま modals slot にモーダルが表示される
@modals/(.)[code]/page.tsx
がレンダリングされる- URL は
/greeting/[code]
に変わるが、背景のコンテンツは/greeting
のまま
Hard Navigation の場合
- インターセプトが作用せず、通常のページ遷移が発生
@modals
slot は空になり、@modals/default.tsx
が表示される- default.tsx の中身を null にしておくことで、モーダルは表示されない
[code]/page.tsx
が通常通り全画面で表示される
Layout の実装は以下のようになります。
export default function GreetingLayout(
props: LayoutProps<"/greeting">,
) {
return (
<div>
{props.children}
{props.modals}
</div>
);
}
この構造のため、modals として渡す Children は position: fixed
や position: absolute
を使ってオーバーレイ表示にする必要があります。そうしないと、通常の縦並びで表示されます。
モーダルコンポーネントの実装例
Shadcn UI を使った実装例は以下のようになります。
- defaultOpen と open を true に設定して、モーダルを開いた状態で表示
- onOpenChange で router.back() を呼び出すことで、モーダルを閉じる際に前のページに戻る
- これにより、ブラウザの戻るボタンでもモーダルが閉じる動作を実現
"use client";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogOverlay,
DialogTitle,
} from "@/features/ui/components/dialog";
export const GreetingDialog = () => {
const router = useRouter();
const handleOpenChange = () => {
router.back();
};
return (
<Dialog defaultOpen={true} open={true} onOpenChange={handleOpenChange}>
<DialogOverlay>
<DialogContent className="overflow-y-hidden">
<DialogTitle>Greeting</DialogTitle>
</DialogContent>
</DialogOverlay>
</Dialog>
);
};
これにて、ページからのアクセスであればモーダルを開きつつ、共有可能な URL を持つ素敵な UI ができあがります。
さいごに
既存の useImperativeHandle、React 19.2 から追加された様々な機能、Next.js 16 などたくさん書きたいことはありますがいったんは業務で登場したスコープに絞りました。みなさんも最近面白い書き方をしましたか。何かありましたらぜひ共有してください。