🌶️

Reactカスタムフックが可読性を損なうとき

に公開

この記事は React Advent Calender 2025 11 日目の投稿です。

可読性の高いカスタムフックを考える

React 開発において、「ロジックはカスタムフックに切り出す」というプラクティスは、私たちの思考に深く根付いています。しかし、筆者の経験上、「なんとなく」でカスタムフックに切り出されたロジックは、かえってコードの可読性を下げることが多いと感じています。コンポーネントの挙動を理解するために別ファイルを行き来し、分断されたロジックを頭の中でつなぎ合わせなければならないコードは可読性が低く、改修や不具合の調査を難しくします。

本記事では、React のカスタムフックが、かえってコードの可読性を損なってしまうアンチパターンと、その回避策について考察します。「ロジックはカスタムフックに切り出す」という一般的なプラクティスの落とし穴を明らかにし、コンポーネントの凝集度と透明性を維持するための設計基準を提案します。どのような基準で抽象化の価値を見極めるべきかを、具体的なアンチパターンと共に考えていきます。

アンチパターン 1: 過剰な抽象化による凝集度の低下

最初のアンチパターンは、再利用の予定もないのにコンポーネント固有のロジックを機械的にカスタムフックに切り出してしまうケースです。業務コンポーネント[1]における実装を考えます。小さなカウンター UI を持つ Counter コンポーネントを思い浮かべてください。

ボタンクリックでカウントを 1 つ増やすだけのシンプルな挙動なのに、そのロジックを useCounter フックとして分離したとします。この抽象化は一見すると関心の分離を促す良いプラクティスに見えますが、かえって可読性を損なうことがあります。

Bad(再利用予定のないロジックを機械的に分離):

useCounter.ts
// ロジックの分離のためだけに定義されたカスタムフック
export function useCounter() {
  const [count, setCount] = useState(0);
  const increment = () => setCount((c) => c + 1);
  return { count, increment };
}
Counter.tsx
import { useCounter } from './useCounter';

export function Counter() {
  // 挙動を知るために useCounter.ts を見に行く必要がある
  const { count, increment } = useCounter();
  return <button onClick={increment}>Clicked {count} times</button>;
}

このアプローチがもたらす問題は、コンポーネントの 凝集度 が低下することです。UI とその振る舞いが別々のファイルに分断されてしまうと、「このボタンはどう動くのか」という挙動を理解するためだけに、ファイル間を移動しなければなりません。このような状態は 関心の分離 (Separation of Concerns) を達成しているように見えて、実際にはコードの理解を妨げる要因となります。

Counterのような単純な例では、この手間はさほど問題にはならないですが、実際のプロダクションコードでは、複数の UI 要素とそれぞれに紐づくロジックが複雑に絡み合います。そのような状況でロジックだけを機械的に分離してしまうと、挙動を追うたびにファイル間を何度もジャンプする必要が生まれ、コードを変更する際の 認知的な負荷 が増大してしまいます。

こうした理由から、コンポーネントテストで品質を担保できているなら、ロジックはコンポーネント内にとどめて凝集度を高く保ったほうが、結果としてずっと読みやすい と考えています。加えて、インタラクティブな要素に、ユーザーにとって認識しやすいラベルテキストが備わっていれば、多くの場合でインラインにイベントハンドラを定義しても可読性に影響はしないでしょう。

Good(コンポーネント内で実装を完結する):

Counter.tsx
export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button
      onClick={() => {
        setCount((c) => c + 1);
      }}
    >
      Clicked {count} times
    </button>
  );
};

この考え方に従うと、コンポーネントが肥大化したときに、関心と責務に基づいてコンポーネントそのものを分割する意識が自然と生まれます。逆に、DOM とロジックを機械的に分離したコードベースでは、コンポーネントの肥大化に気づきにくくなる傾向があります。

アンチパターン 2: ライブラリフックの合成による不安定なインターフェース

次に挙げるのは、TanStack Query の useQuery や React Hook Form の useForm といった強力なライブラリフックをラップし、独自のインターフェースを持つカスタムフックを作ってしまうアンチパターンです。以下のコードで示す useUserProfileForm のように、内部で複数のライブラリフックを呼び出し、その結果を独自のインターフェースでラップすることで、状態の出所を追跡しづらくしてしまう実装が、このアンチパターンの典型例です。

Bad(独自実装で統一性の無いインターフェース):

useUserProfileForm.ts
function useUserProfileForm({ id }) {
  const { data: user, isLoading, error } = useQuery(userQuery(id));
  const { mutate: mutateUpdateUser } = useMutation(updateUserMutation);
  const { handleSubmit, formState: { isDirty }, watch } = useForm<UserForm>({
    values: user ?? { name: "", email: "" },
  });
  const name = watch("name");
  const onSubmit = handleSubmit((values) => mutateUpdateUser({ id, values }));

  return {
    /**
     * コンポーネントから見ると user, name の値の出どころが不明なので
     * コードリーディング時にメンタルモデルを構築しづらい
     */
    user,
    name,
    status: isLoading ? "loading" : "ready", // 独自の status で学習コストが高い
    isDirty, // 他のformStateを利用するためには都度returnに追加する必要がある
    onSubmit, // コンポーネントから見ると振る舞いが一切見えない
  };
}

このアプローチは、ライブラリ標準の予測可能なインターフェースを隠蔽し、実装ごとに独自のルールを読み解く負担を開発者に強制します。結果として、そのカスタムフックは学習コストの高い 不安定な抽象 となってしまいます。

筆者がクリーンだと感じるのは、ライブラリが用意したインターフェースを、そのまま読めるコードです。フックを組み合わせるときも、透明性 (Transparency) を壊さないことを第一に考え、独自インターフェースの実装を避けます。

Good(透明性を維持):

UserProfileForm.tsx
const UserProfileForm = ({ id }) => {
  // TanStack Queryの API がそのまま使えて予測可能
  const { data: user, isLoading, error } = useQuery(userQuery(id));
  const { mutate: mutateUpdateUser } = useMutation(updateUserMutation);
  // React Hook Form の API がそのまま使えて予測可能
  const {
    handleSubmit,
    formState: { isDirty },
    watch,
  } = useForm<UserForm>({
    values: user ?? { name: "", email: "" },
  });
  const name = watch("name");

  if (isLoading) return <Spinner />;
  if (error) return <Error />;

  return <form onSubmit={handleSubmit((values) => mutateUpdateUser({ id, values }))}>...</form>;
};

いつカスタムフックに切り出すべきか?

ここまでの議論を踏まえ、筆者がカスタムフックの切り出しを検討するときの基準を、具体例とともに整理します。判断の流れをざっくり描くと次のようになります。

ドメイン非依存の汎用責務を再利用する場合

アプリケーション全体で何度も使う汎用的なロジックはカスタムフックの好例です。useLocalStorageuseWindowSize のように、どの画面でも同じ形で使えるカスタムフックは、コンポーネントから切り離すことで重複を防ぎつつ読みやすさも保てます。

ここでの「共通責務」は、業務ドメインに依存せず汎用的に使い回す領域(ブラウザや OS が決める振る舞いなど、例: ローカルストレージ I/O、ウィンドウサイズ監視)を指します。

なお、この手の汎用カスタムフックは react-use などの OSS で十分まかなえることも多いです。開発フェーズやチームのスキルセットに応じて、内製するか OSS を採用するかを柔軟に決めるのがよいでしょう。

単一責務の副作用をカプセル化したい場合

特定のコンポーネントに固有の副作用でも、責務を切り出して可読性を保ちたいケースがあります。たとえば業務コンポーネントのフォームで「特定の入力フィールドにフォーカスが当たったら外部の計算サービスを叩き、結果をローカル状態に反映する」といった処理は、ref callback ごと切り出して副作用を一点にまとめることで、コンポーネント本体の肥大化を防げます。

useQuoteOnFocus.ts
/**
 * フィールドにフォーカスしたときに見積もり値を取得する副作用をカプセル化
 */
export function useQuoteOnFocus(onQuote: (price: number) => void) {
  return useCallback((el: HTMLInputElement | null) => {
    if (!el) return;
    const handleFocus = async () => {
      const price = await fetchQuote();
      onQuote(price);
    };
    el.addEventListener("focus", handleFocus);
    return () => el.removeEventListener("focus", handleFocus);
  }, [onQuote]);
}

ライブラリフックのポリシー化が明確な場合

useQuery, useForm などを全画面で共通のポリシーに沿って使うなら、薄いラップで方針を強制する価値があります。例として、useForm は必ず values を受け取るというルールを型で担保するヘルパーを置くケースです。

useFormWithValues.ts
export function useFormWithValues<FieldValues extends object>(
  props: UseFormProps<FieldValues> & { values: FieldValues },
) {
  return useForm(props);
}

このようなラップでも返却インターフェースは標準形状を保ち、透明性を維持することを重視します。

親子で状態をリフトアップする契約がある場合

親子間で「親がフォーム状態を保持し、子は描画に専念する」という契約がある場合は、カスタムフックで状態をリフトアップすると整合しやすくなります。UserProfileCreateFormUserProfileEditForm に共通する年月日フォームを useUserProfileForm でまとめ、親がその返り値を握る構成です。業務ロジック(前月・次月に進めるなど)は { form, goPrevMonth } のように別キーで返し、formuseForm の戻り値をそのまま渡して標準のインターフェースを保ちます。

useUserProfileForm.ts
type UserProfileFormValues = { date: string }; // 例: "2024-12-25"

export function useUserProfileForm(values: UserProfileFormValues) {
  const form = useForm<UserProfileFormValues>({ values });

  const goPrevMonth = () => {
    const current = new Date(form.getValues("date"));
    const prev = new Date(current.getFullYear(), current.getMonth() - 1, current.getDate());
    form.setValue("date", prev.toISOString().slice(0, 10));
  };
  const goNextMonth = () => {
    const current = new Date(form.getValues("date"));
    const next = new Date(current.getFullYear(), current.getMonth() + 1, current.getDate());
    form.setValue("date", next.toISOString().slice(0, 10));
  };

  // 業務ロジックは別キーで返し、form はそのまま透明性を保って返す
  return { form, goPrevMonth, goNextMonth };
}
UserProfileForm.tsx
type UserProfileFormProps = ReturnType<typeof useUserProfileForm> & {
  onSubmit: (values: UserProfileFormValues) => void;
};

export function UserProfileForm({
  form,
  onSubmit,
  goPrevMonth,
  goNextMonth,
}: UserProfileFormProps) {
  const { register, handleSubmit, watch } = form;
  const date = watch("date");

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <button type="button" onClick={goPrevMonth}>
          前月
        </button>
        <span>{date}</span>
        <button type="button" onClick={goNextMonth}>
          次月
        </button>
      </div>
      <input type="date" {...register("date")} />
    </form>
  );
}

まとめ

カスタムフックは使いどころ次第で可読性に影響します。押さえたいポイントは次のとおりです。

  • 凝集度を優先する: 再利用の予定がないコンポーネント固有のロジックは、コンポーネント内に留める。UI とロジックを同じ場所で読むことで、認知的な負荷を低減する。
  • 透明性を維持する: ライブラリフックをラップする際は、そのインターフェース(返り値の形状など)を可能な限り維持する。独自のインターフェースは、学習コストの高い不安定な抽象になりやすい。
  • 価値ある抽象化を見極める: 特定の業務に依存しない汎用的なカスタムフック、単一責務の副作用を一点にまとめるカプセル化、ポリシー化されたライブラリラップ、親子で状態をリフトアップする契約など、明確なメリットと合意がある場合にのみカスタムフックへの切り出しを検討する。

この記事で紹介した考え方は、AHA (Avoid Hasty Abstractions)、つまり「拙速な抽象化を避ける」という原則に基づいています。

今回の内容は筆者の Opinionated な考え方です。必要に応じて取捨選択し、皆さんのチームでより良い設計を議論するきっかけになれば幸いです。

参考


React Advent Calender 2025 12 日目は ayies128 さんの「useMemo とか理解せずに実装している俺へ」です。引き続きお楽しみください!

https://qiita.com/advent-calendar/2025/react

脚注
  1. 特定の業務ユースケースの達成に特化したコンポーネントのこと ↩︎

Discussion