この記事は弁護士ドットコム Advent Calendar 2025の 8 日目の記事です。
クラウドサイン事業本部でエンジニアをやっている田邉です。
長い期間フロントエンドコードを運用した結果、Fat Component が増えてしまいコード全体が複雑なものになってしまった経験はないでしょうか。
これを回避(または低減)するために私は、ロジックをカスタム hook に切り出すなどの対策を講じてきました。 そんな中で私はロジックをすべて hook に閉じ込め、tsx は UI に専念させることで関心を分離させられることに気づきました。
ルールはシンプルです。
- tsx は UI に専念させる
- ロジックは 1 つの “オーケストレーター hook” に集約する
※ プロジェクトでは Next.js を利用しているため、サンプルコードは Next.js のものになります。また本記事ではクライアントコンポーネントを対象とした設計について記載しています。
すべてのロジックをオーケストレーターhookに
まずは実際に実装を見て理解しましょう。 愚直に実装したものが以下のコードです。
ちなみにこちらは実際に私が担当したプロジェクトのコードをもとにしたものです(ドメイン領域などは変更しています)。
"use client"; import { Modal } from "@/components/modal/modal"; import { DraftForm } from "@/features/draft-form/draft-form"; import type { DraftForm as DraftFormValues } from "@/types/draft"; import { DraftTextPane } from "@/features/drafts-pane/drafts-pane"; import type { Project } from "@/types/project"; import { useRef, useState } from "react"; import { cancelButton, completeButton, container, formContainer, modalButtonContainer, modalCancelButton, modalConfirmButton, navigationButton, navigationContainer, personaContentContainer as draftContentContainer, personaCounter as draftCounter, personasContainer as draftsContainer, saveButton, saveButtonContainer, title, } from "./draft-create.css"; import { useCreateDraft } from "./use-create-draft"; interface DraftCreateProps { projects: Project[]; } export function DraftCreate({ projects }: DraftCreateProps) { const [requirements, setRequirements] = useState<DraftFormValues>({ projectId: { title: "プロジェクト", type: "select", content: "" }, goal: { title: "目的", type: "textarea", content: "" }, description: { title: "概要", type: "textarea", content: "" }, tone: { title: "文体", type: "select", content: "" }, audience: { title: "想定読者", type: "textarea", content: "" }, constraints: { title: "制約", type: "textarea", content: "" }, length: { title: "文量", type: "textarea", content: "" }, styleGuide: { title: "スタイルガイド", type: "textarea", content: "" }, }); const [drafts, setDrafts] = useState<string[]>([]); const [currentIdx, setCurrentIdx] = useState(0); const [selectedDrafts, setSelectedDrafts] = useState<Set<number>>(new Set()); const [selectedProjectId, setSelectedProjectId] = useState<string>(""); const [selectedTemplateId, setSelectedTemplateId] = useState<string>(""); const modalRef = useRef<HTMLDialogElement>(null); const { handleCreateDraft, isPending } = useCreateDraft(); const handleSetDrafts = (drafts: string[], req: DraftFormValues) => { setDrafts(drafts); setRequirements(req); setCurrentIdx(0); }; const handlePrev = () => { setCurrentIdx((idx) => (idx > 0 ? idx - 1 : idx)); }; const handleNext = () => { setCurrentIdx((idx) => (idx < drafts.length - 1 ? idx + 1 : idx)); }; const handleProjectChange = (projectId: string) => { setSelectedProjectId(projectId); setSelectedTemplateId(""); }; const handleTemplateChange = (templateId: string) => { setSelectedTemplateId(templateId); }; const handleSaveDraft = () => { setSelectedDrafts((prev) => { const next = new Set(prev); next.add(currentIdx); return next; }); }; const handleCancelDraft = () => { setSelectedDrafts((prev) => { const next = new Set(prev); next.delete(currentIdx); return next; }); }; const isCurrentDraftSelected = selectedDrafts.has(currentIdx); const handleComplete = () => { modalRef.current?.showModal(); }; const handleConfirmComplete = async () => { const selectedDraftsArray = Array.from(selectedDrafts).map( (index) => drafts[index] ); if (selectedDraftsArray.length > 0) { if (!selectedProjectId) { alert("プロジェクトを選択してください"); return; } if (!selectedTemplateId) { alert("テンプレートを選択してください"); return; } await handleCreateDraft({ draftRequest: requirements, drafts: selectedDraftsArray, aiModel: "default-draft-model", projectId: selectedProjectId, templateId: selectedTemplateId, }); } setDrafts([]); setRequirements({ projectId: { title: "プロジェクト", type: "select", content: "" }, goal: { title: "目的", type: "textarea", content: "" }, description: { title: "概要", type: "textarea", content: "" }, tone: { title: "文体", type: "select", content: "" }, audience: { title: "想定読者", type: "textarea", content: "" }, constraints: { title: "制約", type: "textarea", content: "" }, length: { title: "文量", type: "textarea", content: "" }, styleGuide: { title: "スタイルガイド", type: "textarea", content: "" }, }); setCurrentIdx(0); setSelectedDrafts(new Set()); setSelectedProjectId(""); setSelectedTemplateId(""); modalRef.current?.close(); }; const handleCancelComplete = () => { modalRef.current?.close(); }; return ( <div className={container}> <div className={formContainer}> <h1 className={title}>ドラフトを設計する</h1> <DraftForm handleSetDrafts={handleSetDrafts} requirements={requirements} projects={projects} selectedProjectId={selectedProjectId} handleProjectChange={handleProjectChange} handleTemplateChange={handleTemplateChange} /> </div> {drafts.length > 0 && ( <div className={draftsContainer}> <div className={navigationContainer}> <button type="button" onClick={handlePrev} disabled={currentIdx === 0} className={navigationButton} > < </button> <div className={draftContentContainer}> {selectedDrafts.size > 0 && ( <div style={{ marginTop: 8, fontSize: 14, color: "#666" }}> 選択済み: {selectedDrafts.size}件 </div> )} <div className={saveButtonContainer}> {isCurrentDraftSelected ? ( <button type="button" onClick={handleCancelDraft} className={cancelButton} > 保存をキャンセル </button> ) : ( <button type="button" onClick={handleSaveDraft} className={saveButton} > このドラフトを保存 </button> )} <button type="button" onClick={handleComplete} className={completeButton} > 完了 </button> </div> <DraftTextPane value={drafts[currentIdx] ?? ""} /> <div className={draftCounter}> {drafts.length > 0 ? `ドラフト${currentIdx + 1} / ${drafts.length}` : ""} </div> </div> <button type="button" onClick={handleNext} disabled={currentIdx === drafts.length - 1} className={navigationButton} > > </button> </div> </div> )} <Modal title="完了確認" modalRef={modalRef}> <p>ドラフトの選択を完了しますか?</p> <p>この操作により、保存されていないドラフトは破棄されます。</p> <div className={modalButtonContainer}> <button type="button" onClick={handleCancelComplete} className={modalCancelButton} disabled={isPending} > キャンセル </button> <button type="button" onClick={handleConfirmComplete} className={modalConfirmButton} disabled={isPending} > {isPending ? "保存中..." : "保存して終了"} </button> </div> </Modal> </div> ); }
多くの state やメソッドが含まれており、ロジック部分が大きくなってしまっています。 また 1 つのメソッドで複数の state のセッターを呼び出していることもあり、読み解くのに時間を要します。 このコードのロジックをカスタム hook に切り出してみましょう。
オーケストレーター hook
ロジックを切り出したオーケストレーター hook は以下のように実装します。
import { useCallback, useMemo, useReducer, useState } from "react"; type DraftForm = { projectId: { title: string; type: "select"; content: string }; goal: { title: string; type: "textarea"; content: string }; description: { title: string; type: "textarea"; content: string }; tone: { title: string; type: "select"; content: string }; }; type State = { requirements: DraftForm; drafts: string[]; currentIdx: number; selected: Set<number>; projectId: string; templateId: string; isModalOpen: boolean; }; type Action = | { type: "SET_DRAFTS"; drafts: string[]; requirements: DraftForm } | { type: "PREV" } | { type: "NEXT" } | { type: "TOGGLE_SELECT_CURRENT" } | { type: "SET_PROJECT"; projectId: string } | { type: "SET_TEMPLATE"; templateId: string } | { type: "OPEN_MODAL" } | { type: "CLOSE_MODAL" } | { type: "RESET" }; const initialRequirements = (): DraftForm => ({ projectId: { title: "プロジェクト", type: "select", content: "" }, goal: { title: "目的", type: "textarea", content: "" }, description: { title: "概要", type: "textarea", content: "" }, tone: { title: "文体", type: "select", content: "" }, }); const init: State = { requirements: initialRequirements(), drafts: [], currentIdx: 0, selected: new Set(), projectId: "", templateId: "", isModalOpen: false, }; function reducer(state: State, action: Action): State { switch (action.type) { case "SET_DRAFTS": return { ...state, drafts: action.drafts, requirements: action.requirements, currentIdx: 0, }; case "PREV": return { ...state, currentIdx: Math.max(0, state.currentIdx - 1) }; case "NEXT": return { ...state, currentIdx: Math.min(state.drafts.length - 1, state.currentIdx + 1), }; case "TOGGLE_SELECT_CURRENT": { const next = new Set(state.selected); next.has(state.currentIdx) ? next.delete(state.currentIdx) : next.add(state.currentIdx); return { ...state, selected: next }; } case "SET_PROJECT": return { ...state, projectId: action.projectId, templateId: "" }; case "SET_TEMPLATE": return { ...state, templateId: action.templateId }; case "OPEN_MODAL": return { ...state, isModalOpen: true }; case "CLOSE_MODAL": return { ...state, isModalOpen: false }; case "RESET": return { ...init, requirements: initialRequirements() }; default: return state; } } async function createDraftsAction(input: { request: DraftForm; drafts: string[]; projectId: string; templateId: string; }) { // API 呼び出しを想定 await new Promise((r) => setTimeout(r, 600)); } export function useDraftCreate() { const [state, dispatch] = useReducer(reducer, init); const [isPending, setIsPending] = useState(false); const vm = useMemo(() => { const isCurrentSelected = state.selected.has(state.currentIdx); const canPrev = state.currentIdx > 0; const canNext = state.currentIdx < state.drafts.length - 1; const counter = state.drafts.length > 0 ? `ドラフト ${state.currentIdx + 1} / ${state.drafts.length}` : ""; return { ...state, isCurrentSelected, selectedCount: state.selected.size, canPrev, canNext, counter, currentText: state.drafts[state.currentIdx] ?? "", }; }, [state]); const setDrafts = useCallback((drafts: string[], requirements: DraftForm) => { dispatch({ type: "SET_DRAFTS", drafts, requirements }); }, []); const prev = useCallback(() => { dispatch({ type: "PREV" }); }, []); const next = useCallback(() => { dispatch({ type: "NEXT" }); }, []); const toggleSelectCurrent = useCallback(() => { dispatch({ type: "TOGGLE_SELECT_CURRENT" }); }, []); const setProject = useCallback((projectId: string) => { dispatch({ type: "SET_PROJECT", projectId }); }, []); const setTemplate = useCallback((templateId: string) => { dispatch({ type: "SET_TEMPLATE", templateId }); }, []); const openModal = useCallback(() => { dispatch({ type: "OPEN_MODAL" }); }, []); const closeModal = useCallback(() => { dispatch({ type: "CLOSE_MODAL" }); }, []); const resetAll = useCallback(() => { dispatch({ type: "RESET" }); }, []); const confirmComplete = useCallback(async () => { if (state.selected.size === 0) return; if (!state.projectId) { alert("プロジェクトを選択してください"); return; } if (!state.templateId) { alert("テンプレートを選択してください"); return; } const selectedDrafts = Array.from(state.selected).map((i) => state.drafts[i]); setIsPending(true); try { await createDraftsAction({ request: state.requirements, drafts: selectedDrafts, projectId: state.projectId, templateId: state.templateId, }); dispatch({ type: "RESET" }); } finally { setIsPending(false); } }, [state]); const act = useMemo( () => ({ setDrafts, prev, next, toggleSelectCurrent, setProject, setTemplate, openModal, closeModal, resetAll, confirmComplete, }), [ setDrafts, prev, next, toggleSelectCurrent, setProject, setTemplate, openModal, closeModal, resetAll, confirmComplete, ], ); return [vm, act, isPending] as const; }
実装のポイントを説明します。
1. 返り値は [vm, act, isPending] に限定する
コンポーネントに必要なものをUIの視点で大きく 2 つに限定します。
1 つは表示に必要な出来上がったデータ (vm = ViewModel) で、もう1つは画面操作に対応する動詞 (act = Actions) です。 これに加えて、必要に応じてですが、データロード中などを表現するための isPending も返しておくことにします。
これにより、“UIが欲しいものだけ”を約束するので、使用側から見たAPI面が一気に小さくなります。
2. reducerを“純粋”に保つ
actの中身は非同期・副作用(アラート、ルーター遷移など)・複合ユースケースのオーケストレーション(例えば、検証→API→結果に応じた dispatch)になるでしょう。 useReducer の reducer はオーケストレーションの中で発生する同期の状態遷移だけを扱う純粋関数として保つようにしましょう。
例えば confirmComplete は画面操作に呼応して発生する非同期の処理ですが、処理本体は act 内に記載しています。 そして非同期処理の結果発生する状態遷移の処理は reducer に定義し、dispatch({ type: "RESET" }); でこれを呼び出しています。
上記のポイントを抑えることで、単にロジックを丸ごと hook に移行するのではなく、テスタビリティ、可読性、実装の一貫性を高めることができます。
ロジック切り出し後の tsx
前節でコンポーネントのロジックをオーケストレーター hook に切り出しました。 その結果 tsx の実装がどのようになったか見てみましょう。
"use client"; import { useDraftCreate } from "./use-draft-create"; function DraftForm(props: { requirements: any; projects: Array<{ id: string; name: string }>; selectedProjectId: string; onSetDrafts: (drafts: string[], req: any) => void; onProjectChange: (id: string) => void; onTemplateChange: (id: string) => void; }) { /* …フォームUI… */ return <div>フォーム(省略)</div>; } function DraftTextPane({ value }: { value: string }) { return <pre style={{ whiteSpace: "pre-wrap" }}>{value}</pre>; } function Modal({ title, open, onClose, children, }: { title: string; open: boolean; onClose: () => void; children: React.ReactNode; }) { if (!open) return null; return ( <div role="dialog" aria-modal> <h2>{title}</h2> {children} <button onClick={onClose}>×</button> </div> ); } export function DraftCreate({ projects }: { projects: Array<{ id: string; name: string }> }) { const [vm, act, isPending] = useDraftCreate(); return ( <div> <h1>ドラフトを設計する</h1> <DraftForm requirements={vm.requirements} projects={projects} selectedProjectId={vm.projectId} onSetDrafts={act.setDrafts} onProjectChange={act.setProject} onTemplateChange={act.setTemplate} /> {vm.drafts.length > 0 && ( <div> <button onClick={act.prev} disabled={!vm.canPrev}> < </button> <div> {vm.selectedCount > 0 && <div>選択済み: {vm.selectedCount}件</div>} <div> <button onClick={act.toggleSelectCurrent}> {vm.isCurrentSelected ? "保存をキャンセル" : "このドラフトを保存"} </button> <button onClick={act.openModal}>完了</button> </div> <DraftTextPane value={vm.currentText} /> <div>{vm.counter}</div> </div> <button onClick={act.next} disabled={!vm.canNext}> > </button> </div> )} <Modal title="完了確認" open={vm.isModalOpen} onClose={act.closeModal}> <p>選択したドラフトを保存しますか?未保存のドラフトは破棄されます。</p> <div> <button onClick={act.closeModal} disabled={isPending}> キャンセル </button> <button onClick={async () => { await act.confirmComplete(); act.closeModal(); }} disabled={isPending} > {isPending ? "保存中..." : "保存して終了"} </button> </div> </Modal> </div> ); }
先ほど作成したオーケストレーター hook が useDraftCreate です。 DraftCreate コンポーネントのロジックはすべて useDraftCreate に移行されており、UI に集中できています。
まとめ
- tsx は UI に専念させる
- ロジックは 1 つの “オーケストレーター hook” に集約する
というシンプルなルールで UI とロジックに関する関心の分離を実現しました。 オーケストレーター hook については
- 返り値は [vm, act, isPending] に限定する
- reducer を“純粋”に保つ
というルールのもと、テスタビリティ、可読性、実装の一貫性を高め、hook 内で複雑な処理が発生するのを防いでいます。
得られるメリット
不要な API の露出を防止できている
上記の構成ではすでに説明したとおり、hook が返す API の表面積を小さくできています。 つまり、他 hook で利用するため、などの API 露出が不要なので本当に UI で利用するもの以外は hook から外に出さなくても済んでいます。
テスト容易性
reducer を純粋に保つことでテストが容易になります。 また本実装ではすべてのロジックを hook 内に記載していますが、非同期や副作用を伴う処理は別ファイルのメソッドに切り出して、それをインポートして利用することもできます。 ロジックをファイルで分割することによって、さらにユニットテストがしやすくなることでしょう。
再レンダリングの制御
状態の変更はすべて hook 内で行われます。 そして変更された状態は vm として UI に提供されます。 このとき、子コンポーネントに vm を丸ごと渡すのではなく、本当に必要なプリミティブ値のみを渡すように注意してください。 また reducer は変更のないフィールドは元の値から変更しないようにしてください。 以下の実装ではモーダルの開閉を制御する状態以外は変更しないようにしています。
case "OPEN_MODAL": return { ...state, isModalOpen: true };
上記を意識して実装することで子コンポーネントの過剰な再レンダリングを防止できます。