概要
Reactを書いている際に、「UI」と「ロジック」が密結合して複雑化・肥大化している状態になることがあると思います。
カスタムhookを使うことで、ロジックを抽象化し、再利用可能な単位として設計することができます。
この記事では、カスタムhookを「作る」のではなく「設計する」という視点から、
- なぜカスタムフックが必要なのか
- 良いフックと悪いフック
- TypeScriptと組み合わせた型安全な設計
などを解説していこうと思います。
カスタムhookとは
定義
カスタムhookとは、React Hooksを使った**「ロジックの再利用単位」
function useSomething() {
// useState, useEffect, useCallback ...
return { /* ... */ };
}
- 名前は必ず
useから - React Hooksのルールに従う(条件分岐内で呼べないなど)
- UIを返さない(JSXではなく、状態やハンドラを返す)
なぜ「use」で始まる必要があるのか
これは単なる慣習ではありません。
Reactコンパイラは、useで始まる関数を見つけることでhookのルール違反をチェックしたりします。
つまり、useはコンパイラへのヒントであり、静的解析を可能にする重要なルーツになります。
## なぜカスタムフックが必要か
悪い例
export function UserProfile() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch("/api/user")
.then(res => {
if (!res.ok) throw new Error("Failed");
return res.json();
})
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <div>{user?.name}</div>;
}
- 再利用がめんどくさい
- テストが困難
- 責務が不明確
- メンテが大変
カスタムhookで分離
export function useUser() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch("/api/user")
.then(res => {
if (!res.ok) throw new Error("Failed");
return res.json();
})
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { user, loading, error };
}
export function UserProfile() {
const { user, loading, error } = useUser();
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <div>{user?.name}</div>;
}
- 再利用容易 :
useUser()を呼びやすい - テスト容易
- 責務の明確化
- 変更の局所化
良いカスタムhookの設計
単一責務の原則
hookは1つのことだけやるべき
複数の責務を持つhook
function useUserAndModalAndToast() {
const [user, setUser] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [toast, setToast] = useState("");
// ...
}
責務を分離
function useUser() { /* ... */ }
function useModal() { /* ... */ }
function useToast() { /* ... */ }
動詞と名詞で説明がつく範囲にするといいかもしれません。
UIに依存しない
hookはロジックのみを扱うべき
UIに依存するhook
function useAlert() {
const showError = (message: string) => {
alert(message); // ブラウザのalertに依存
};
return { showError };
}
状態のみ
function useError() {
const [error, setError] = useState<string | null>(null);
const clearError = () => setError(null);
return { error, setError, clearError };
}
function MyComponent() {
const { error, setError } = useError();
// 表示方法はコンポーネント側で決める
return error ? <Toast message={error} /> : null;
}
返り値はobjectに
配列返し
function useToggle() {
const [value, setValue] = useState(false);
return [value, setValue]; // useState風だが...
}
// 使う側
const [isOpen, setIsOpen] = useToggle();
拡張が面倒になりやすい
object返し
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = () => setValue(v => !v);
const setTrue = () => setValue(true);
const setFalse = () => setValue(false);
return { value, toggle, setValue, setTrue, setFalse };
}
// 使う側
const { value: isOpen, toggle: toggleModal } = useToggle();
依存配列を適切に設計
依存配列が不適切
function useData(url: string) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(url).then(res => res.json()).then(setData);
}, []); // urlが変わっても再取得されない!
return data;
}
適切な依存配列
function useData(url: string) {
const [data, setData] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(d => {
if (!cancelled) setData(d);
});
return () => { cancelled = true; }; // クリーンアップ
}, [url]); // url変更時に再実行
return data;
}
依存配列にはhook内で参照する外部の値をすべて含める
TypeScriptによる型安全な設計
ジェネリクスで柔軟性を持たせる
export function useAsync<T, E = Error>() {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<E | null>(null);
const execute = useCallback(async (promise: Promise<T>) => {
setLoading(true);
setError(null);
try {
const result = await promise;
setData(result);
return result;
} catch (e) {
setError(e as E);
throw e;
} finally {
setLoading(false);
}
}, []);
return { data, loading, error, execute };
}
使用例:
function UserList() {
const { data: users, loading, execute } = useAsync<User[]>();
useEffect(() => {
execute(fetch("/api/users").then(res => res.json()));
}, [execute]);
// ...
}
返り値の型を明示的に定義
type UseToggleReturn = {
value: boolean;
toggle: () => void;
setValue: (value: boolean) => void;
setTrue: () => void;
setFalse: () => void;
};
export function useToggle(initial = false): UseToggleReturn {
// 実装
}
APIドキュメントとして機能し、型エクスポートで他のファイルから参照可能
オプションオブジェクトで拡張性を確保
type UseFetchOptions<T> = {
initialData?: T;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
retry?: number;
retryDelay?: number;
};
export function useFetch<T>(
url: string,
options: UseFetchOptions<T> = {}
) {
const {
initialData = null,
onSuccess,
onError,
retry = 0,
retryDelay = 1000,
} = options;
// 実装
}
使用例:
const { data } = useFetch<User[]>("/api/users", {
onSuccess: (users) => console.log(`Loaded ${users.length} users`),
retry: 3,
});
パフォーマンスの最適化
useCallbackとuseMemoの適切な使用
カスタムhook内では、返す関数やオブジェクトを適切にメモ化します。
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
// メモ化しないと、毎回新しい関数が生成される
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
// オブジェクトもメモ化
return useMemo(
() => ({ count, increment, decrement, reset }),
[count, increment, decrement, reset]
);
}
また、カスタムhookは基本的にRSCのみで利用可能です。
テスト
カスタムhookのテスト
testing-library のrenderHookを使います。
import { renderHook, act } from "@testing-library/react";
import { useToggle } from "./useToggle";
describe("useToggle", () => {
it("should toggle value", () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current.value).toBe(false);
act(() => {
result.current.toggle();
});
expect(result.current.value).toBe(true);
});
it("should set to true", () => {
const { result } = renderHook(() => useToggle(false));
act(() => {
result.current.setTrue();
});
expect(result.current.value).toBe(true);
});
});
非同期フックのテスト
import { renderHook, waitFor } from "@testing-library/react";
import { useAsync } from "./useAsync";
describe("useAsync", () => {
it("should handle successful execution", async () => {
const { result } = renderHook(() => useAsync<string>());
act(() => {
result.current.execute(Promise.resolve("data"));
});
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.data).toBe("data");
expect(result.current.error).toBeNull();
});
});
});
## まとめ
カスタムフックは単なるコードの分割ではなく、ロジック部分を抽象化することでいい感じに再利用可能にする設計技術といえます。
一つのhookには一つの責務だけをあたえ、UIから独立させるようにすることで、テストやメンテが容易になります。