🍎

私的Custom Hooksを使う際のモチベーション

2022/11/07に公開

はじめに

最近携わっているプロジェクトでCustom Hooksを試行錯誤し活用していくなか、私の中でこんなモチベーションで使うと概ね間違いがないのでは?
という軸が見えてきたので、いくつかの実装例を添えて今後の自分のために整理し記事にしました。

前提

Custom Hooksは仕様がとても柔軟かつ自由なため個々で解釈が異なると考えています。
私の中でコアとして持っている考え方を一つだけ定義させていただきます。
それは原則Presentationalな部分をCustom Hooksに持ち込まないです。

こういった考えの人間が書いているんだな程度で読んでいただけると助かります。

結論

私の中では主に次の2つの動機によってCustom Hooksを作成しています。

  • 特定のコンポーネントを意識しない抽象度の高い一連の処理をまとめたい
  • 特定のライブラリへの依存などをコアドメイン[1]から隠蔽したい(React本体は記事のアプローチの前提になるため除外します)

抽象度の高い一連の処理をまとめたい

Ex.画面座標のキャプチャ

例えば、ユーザが画面上をClickした際の座標を常にキャプチャしたいといったような場合は抽象度が高い一連の処理と言えると思います。

usePointer.ts
import { useCallback, useEffect, useState } from "react";

export const usePointer = () => {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  const onCatchMouseEvent = useCallback((e: MouseEvent) => {
    setX(e.clientX);
    setY(e.clientY);
  }, []);

  useEffect(() => {
    document.addEventListener("click", onCatchMouseEvent);
    return () => {
      document.removeEventListener("click", onCatchMouseEvent);
    };
  });

  return {
    poninter: { x, y },
  };
};

App.tsx
import "./App.css";
import { usePointer } from "./hooks/usePointer";

function App() {
  const { poninter } = usePointer();
  return (
    <div className="App">
      <div>
        <span>
          X: {poninter.x}, Y: {poninter.y}
        </span>
      </div>
    </div>
  );
}

export default App;

このようにアプリ全体で使われる可能性が高くコアドメインと直接関係のない処理はCustom Hooksに隠蔽することでAPIを利用するコンポーネントはシンプルになりコアドメインの実装に集中できます。

特定のライブラリへの依存などをコアドメインから隠蔽したい

Clean Architecture 達人に学ぶソフトウェアの構造と設計などでも触れられる、フレームワークなどへの過度な依存は避けたほうが良いという主張を意識したアプローチになります。

特に状態管理ライブラリやFormライブラリは性質的にアプリケーションが過度に依存しやすく、移行コストが高くなる傾向があると私は考えています。
そのため、Custom Hooksなどを通し疎結合に利用できれば強力な恩恵も受けつつアプリのコアドメインを守ることができます。

Ex.状態管理ライブラリの隠蔽

Usersというリソースに対する機能を隠蔽したケースを考えてみます。
Cutom HooksではUsersというリソースを返し、createという副作用のある関数を提供しています。

useUsers.ts
import { useCallback } from "react";
import useSWR from "swr";

export const useUsers = () => {
  const fetcher = useCallback(async () => {
    await new Promise((resolve) => setTimeout(resolve, 1500));
    return [
      { id: 1, name: "fuga" },
      { id: 2, name: "hoge" },
      { id: 3, name: "piyo" },
    ];
  }, []);

  const { data, error } = useSWR("api/users", fetcher);

  const create = useCallback(({ name }: { name: string }) => {
    // 作成のAPIを呼ぶなど
  }, []);

  return {
    users: data || [],
    isLoading: !data && !error,
    create,
  };
};
App.tsx
import "./App.css";
import { useUsers } from "./hooks/useUsers";

function App() {
  const { users } = useUsers(); //Usersというオブジェクトが取得できることだけを期待している
  return (
    <div className="App">
      <div>
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

export default App;

このように一連の副作用をまとめ、APIとして提供することで利用側からUsersがどのように管理されているかを意識する必要が無くなります。
そのため、例ではswrを利用してますが、TanStackQuery (React Query)やRedux、Recoilなどへの変更は容易かと思います。

Ex.Formライブラリの隠蔽

Formの責任範囲の解釈は難しく思っています。
入力の入り口となるinputなどのUI部分も持ち、入力の状態や初期化時の制御など複雑なロジックももっています。
そのためForm状態管理のロジックだけを切り出そうとしても利用側のFormライブラリへの依存を避けるのは大変だと思っています。

FormはUIとFormの状態などを併せ持つ特徴があるため、render hooksという設計パターンを採用しアプローチしています。

例ではReact Hook Formを利用してます。
特徴として非制御コンポーネントなどに特別な関数を与える必要があります。
そのため入力を担うinputなどのコンポーネントがライブラリに依存した作りになります。

今回はライブラリに依存した部分を初期化した状態のコンポーネントを返すhooksを作成し、利用側は通常のinputなどを並べるのと同じように使える算段です。

useUserForm.tsx
import { ComponentPropsWithoutRef, memo, useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";

type UserForm = {
  name: string;
  age: number;
};

export const useUserForm = (user: {
  id: number;
  name: string;
  age: number;
}) => {
  const { register, handleSubmit, reset } = useForm<UserForm>();

  useEffect(() => {
    reset(user);
  }, [user, reset]);

  const NameInput = memo((props: ComponentPropsWithoutRef<"input">) => (
    <input {...props} {...register("name")} />
  ));

  const AgeInput = memo((props: ComponentPropsWithoutRef<"input">) => (
    <input {...props} {...register("age")} />
  ));

  const submit = useMemo(
    () => handleSubmit((data) => console.log(data)),
    [handleSubmit]
  );

  return {
    NameInput,
    AgeInput,
    submit,
  };
};

import { memo, useMemo } from "react";
import { useUserForm } from "../../hooks/useUserForm";

export const UserForm = memo(() => {
  const user = useMemo(() => ({ id: 1, name: "hoge", age: 27 }), []);
  const { NameInput, AgeInput, submit } = useUserForm(user);

  return (
    <div>
      <div>
        <label htmlFor="name">Enter your name: </label>
        <NameInput />
      </div>
      <div>
        <label htmlFor="age">Enter your age: </label>
        <AgeInput type="number" />
        {/**type を見た目の要件と考えるとこちらで定義する */}
      </div>
      <button onClick={submit}>Submit</button>
    </div>
  );
});

こうすることで

  • 利用側はFormのUIだけに注力した実装ができる
  • 煩雑なForm初期化処理などが隠蔽され見通しが良い
  • 疎結合なため移行コストなどが極力抑えられると予想

といった効果を期待しています。

最後に

今回は私的Custom Hooksを使う際のモチベーションを整理しました。

いくつかの具体的な実装例を添えることで、未来自分の感が方が変化していった際にも見返せるような構成にしてみました。
ReactはAPIの数が少なく機能がシンプルで利用する場合の余白が多いライブラリだと思います。
今話題のuseもですがReactは今後もこういった表現力を補完していくAPIを提供してくれると思いますので、試行錯誤しながら軸を決めて活用して行きたいと個人的に思っています。

脚注
  1. 私の中ではUIを通して実現したいユーザ体験の実装を指します。 ↩︎

Discussion