はい、承知いたしました。React初心者向けに、カスタムフックの概念から具体的な作成方法、実践例、注意点、テスト方法、ベストプラクティスまでを網羅した、約5000語の詳細な解説記事を作成します。
以下が記事の内容です。
【初心者向け】React カスタムフック徹底ガイド
はじめに:なぜカスタムフックを学ぶ必要があるのか?
Reactを使った開発において、「フック(Hooks)」は今や欠かせない存在です。特にReact 16.8で導入されて以来、関数コンポーネントで状態や副作用を扱うことができるようになり、コンポーネントの書き方が大きく変わりました。
そして、このフックの強力な機能の一つが「カスタムフック」を作成できることです。初心者の方にとっては、標準で提供されている useState
や useEffect
といったフックを使うだけでも最初は大変かもしれません。しかし、少しReact開発に慣れてきて、同じようなロジックを複数のコンポーネントで書いていることに気づき始めたら、それはカスタムフックを学ぶ絶好の機会です。
では、なぜカスタムフックが必要なのでしょうか? 主な理由は以下の通りです。
- ロジックの再利用: アプリケーション開発では、データのフェッチ、フォーム入力の検証、ウィンドウサイズの変化への対応など、様々なコンポーネントで同じようなロジックが必要になることが頻繁にあります。カスタムフックを使えば、これらの共通ロジックをカプセル化し、複数のコンポーネントで簡単に使い回せるようになります。
- コンポーネントの関心の分離: UIの表示ロジック(どう見えるか)と、データの取得や状態の管理といった非UIロジック(どう動くか)を分離できます。これにより、コンポーネントファイルが肥大化するのを防ぎ、よりシンプルで読みやすいコードになります。
- 可読性と保守性の向上: ロジックがカスタムフックとして切り出されることで、各コンポーネントは自身のUIに関することだけに集中できます。これにより、コンポーネントのコードが短く、何をしているのか理解しやすくなります。結果として、コードの保守性も向上します。
- テスト容易性の向上: ロジックがコンポーネントから分離されることで、UIに依存せずにロジック単体でテストしやすくなります。
この記事では、Reactや基本的なフック(useState
, useEffect
など)を使ったことがある方を対象に、カスタムフックの概念から、具体的な作成方法、よくあるパターン、注意点、そしてテスト方法までを徹底的に解説します。約5000語にわたる詳細な説明を通して、カスタムフックを理解し、自信を持って使いこなせるようになることを目指します。
さあ、カスタムフックの世界へ踏み出しましょう!
Reactフックの基本おさらい
カスタムフックは、既存のReactフックを組み合わせて作られるものです。そのため、まずは基本的なフックの役割を簡単におさらいしておきましょう。
useState
:状態管理のフック
関数コンポーネント内で状態(ステート)を持つために使います。
“`jsx
import React, { useState } from ‘react’;
function Counter() {
// count という状態変数と、それを更新する setCount 関数を宣言
const [count, setCount] = useState(0); // 初期値は 0
return (
カウント: {count}
);
}
“`
useState
は、現在の状態の値と、その状態を更新するための関数(セッター関数)のペアを配列として返します。状態が更新されると、コンポーネントが再レンダーされます。
useEffect
:副作用の処理フック
データの取得、DOMの直接操作、イベントリスナーの設定や解除、タイマー処理といった、「副作用」を関数コンポーネントで行うために使います。コンポーネントのレンダー後に実行される処理を定義します。
“`jsx
import React, { useState, useEffect } from ‘react’;
function WindowSizeDisplay() {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
// 副作用の処理: ウィンドウサイズ変更イベントのリスナーを設定
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
// クリーンアップ関数: コンポーネントがアンマウントされる際や
// 依存配列が変更されてエフェクトが再実行される前に呼ばれる
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // 依存配列が空なので、このエフェクトはマウント時とアンマウント時にのみ実行される
return (
ウィンドウ幅: {windowWidth}px
);
}
“`
useEffect
は第一引数に副作用の処理を行う関数、第二引数に依存配列を取ります。依存配列が空([]
)の場合、エフェクトはコンポーネントのマウント時のみ実行され、クリーンアップ関数はアンマウント時に実行されます。依存配列に値を含めると、その値が変更されるたびにエフェクトが再実行され、その前に前回のクリーンアップ関数が実行されます。
useContext
:コンテキストの使用フック
Context APIを使って、コンポーネントツリーを深く辿ることなくデータを共有するために使います。
“`jsx
import React, { useContext, createContext } from ‘react’;
// コンテキストを作成
const ThemeContext = createContext(‘light’); // 初期値は ‘light’
// コンテキストプロバイダーコンポーネント
function App() {
return (
);
}
// コンテキストを使用するコンポーネント
function ThemedButton() {
const theme = useContext(ThemeContext); // コンテキストの値を取得
return (
);
}
“`
useContext
は作成したコンテキストオブジェクトを受け取り、そのコンテキストの現在の値を返します。
カスタムフックは、これらの useState
、useEffect
、useContext
といった標準フックや、他のカスタムフックを組み合わせて、特定のロジックを抽象化し、再利用可能にしたものです。
カスタムフックとは?
改めて、カスタムフックとは何でしょうか?
一言でいうと、「Reactの他のフック(標準フックや他のカスタムフック)を呼び出す、use
で始まるJavaScriptの関数」です。
“`javascript
// これはカスタムフックです
function useSomethingCool() {
// ここで useState や useEffect などのフックを呼び出します
const [state, setState] = useState(/ … /);
useEffect(() => { / … / }, [/ … /]);
// 状態や関数、またはそれらを組み合わせた値を返します
return { state, setState };
}
“`
重要なポイントは以下の通りです。
- ただのJavaScript関数: カスタムフック自体は、特別な構文や機能を持つものではなく、単なるJavaScriptの関数です。
use
から始まる命名規則: 関数名が必ずuse
で始まる必要があります。これはReactがフックのルール(後述)を適用するために必要であり、開発者にとっても「これはフックである」ということが一目でわかる重要な規約です。- 他のフックを呼び出す: カスタムフックの内部では、
useState
,useEffect
,useContext
などの標準フックや、別のカスタムフックを呼び出します。カスタムフックがコンポーネント内で意味を持つのは、その中で呼び出している標準フックのおかげです。 - UIを返さない: 通常のReactコンポーネントとは異なり、カスタムフックはJSXを返してUIを描画することはありません。カスタムフックが返すのは、状態の値、状態を更新する関数、副作用を実行する関数など、コンポーネントがUIを操作したり、状態を管理したりするために必要なロジックに関連する値です。
カスタムフックの目的は、コンポーネント間で状態を持つロジックを共有することです。同じUIロジック(例えば、特定のリストの表示)を複数の場所で使う場合はコンポーネントを作成しますが、同じ状態ロジック(例えば、APIからデータをフェッチしてローディング状態を管理する)を複数のコンポーネントで使いたい場合は、カスタムフックを作成します。
カスタムフックとコンポーネントの違い
カスタムフックとコンポーネントはどちらもコードを再利用するためのものですが、目的と使い方が異なります。
- コンポーネント: UIの一部を定義し、再利用するために使います。Propsを受け取り、JSXを返して画面に表示します。
- カスタムフック: 状態を持つロジックを定義し、再利用するために使います。引数としてデータや設定を受け取り、状態の値や状態を操作する関数などを返します。UIは返しません。
例えば、「ユーザー一覧を表示する」ならコンポーネント、「ユーザーデータをAPIから取得して、ローディング状態やエラー状態も管理する」ならカスタムフック、というように使い分けます。ユーザーデータを取得するカスタムフックを、ユーザー一覧コンポーネントやユーザー詳細コンポーネントなど、データを必要とする様々なコンポーネントで利用できます。
なぜカスタムフックを使うのか?(より深く)
カスタムフックの必要性について、もう少し掘り下げて考えてみましょう。Reactでフックが登場する前は、コンポーネント間でロジックを共有するために「レンダープロップス」や「高階コンポーネント(HOC)」といったパターンが使われていました。これらも強力なパターンでしたが、いくつかの課題がありました。
- レンダープロップス: ネストが深くなりやすく、コードが読みにくくなる「ラッパー地獄」と呼ばれる問題が発生しがちでした。
- 高階コンポーネント: コンポーネントのツリー構造が複雑になり、デバッグが難しくなったり、Propsの名前が衝突したりする問題が発生することがありました。
フック、そしてカスタムフックは、これらの課題を解決するために導入されました。カスタムフックを使うことで、コンポーネントの構造を変えることなく、ロジックだけを抽出して再利用できるようになります。
例えば、とあるモーダルコンポーネントがあり、開閉状態を isOpen
というステートで管理しているとします。別の場所で全く同じ開閉ロジックを持つドロップダウンメニューコンポーネントが必要になった場合、モーダルコンポーネントから開閉に関するステート (isOpen
) とその更新関数 (setIsOpen
)、そして開閉を切り替える関数 (toggleOpen
) をカスタムフックとして切り出せば、モーダルとドロップダウンの両方でそのフックを再利用できます。
Before: ロジックがコンポーネント内に分散している
“`jsx
// Modal.jsx
function Modal() {
const [isOpen, setIsOpen] = useState(false); // 開閉ロジック
const toggleOpen = () => setIsOpen(!isOpen); // 開閉ロジック
// … モーダルのUIを返すJSX
return (
{isOpen && (
モーダルタイトル
モーダルの中身
)}
);
}
// Dropdown.jsx
function Dropdown() {
const [isOpen, setIsOpen] = useState(false); // 開閉ロジック
const toggleOpen = () => setIsOpen(!isOpen); // 開閉ロジック
// … ドロップダウンのUIを返すJSX
return (
{isOpen && (
- アイテム 1
- アイテム 2
)}
);
}
“`
この例では、モーダルコンポーネントとドロップダウンコンポーネントの両方に、開閉状態を管理する全く同じ useState
と toggleOpen
関数が含まれています。
After: カスタムフックでロジックを再利用
まず、開閉ロジックをカスタムフックとして抽出します。
“`javascript
// useToggle.js
import { useState } from ‘react’;
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => {
setValue(currentValue => !currentValue);
};
// 現在の状態の値と、それを切り替える関数を返す
return [value, toggle];
}
export default useToggle;
“`
次に、このカスタムフックをモーダルとドロップダウンのコンポーネントで使用します。
“`jsx
// Modal.jsx
import React from ‘react’;
import useToggle from ‘./useToggle’; // カスタムフックをインポート
function Modal() {
const [isOpen, toggleModal] = useToggle(false); // カスタムフックを使用
// … モーダルのUIを返すJSX
return (
{isOpen && ( // カスタムフックから返された状態を使用
モーダルタイトル
モーダルの中身
{/ カスタムフックから返された関数を使用 /}
)}
);
}
// Dropdown.jsx
import React from ‘react’;
import useToggle from ‘./useToggle’; // カスタムフックをインポート
function Dropdown() {
const [isOpen, toggleDropdown] = useToggle(false); // カスタムフックを使用
// … ドロップダウンのUIを返すJSX
return (
{isOpen && ( // カスタムフックから返された状態を使用
- アイテム 1
- アイテム 2
)}
);
}
“`
このように、開閉ロジックが useToggle
というカスタムフックとして一箇所にまとめられました。各コンポーネントは、そのカスタムフックを呼び出すだけで、必要な状態と関数を取得できます。これにより、コードの重複が解消され、よりクリーンで保守しやすいコードになりました。
さらに重要なのは、各コンポーネントが useToggle
を呼び出すたびに、そのコンポーネント専用の独立した状態 (isOpen
の値) が作成されるということです。つまり、モーダルが開いていても、ドロップダウンは閉じている、というように、それぞれの状態が互いに干渉することなく管理されます。カスタムフックは、ロジックを共有するだけであり、状態自体を共有するわけではないという点を理解しておくことが重要です。(もし状態を共有したい場合は、Context APIや状態管理ライブラリなどを検討します)
カスタムフックの作り方
それでは、実際にカスタムフックをどう作るのか、いくつかのステップと例を通して見ていきましょう。
カスタムフックを作る基本的な流れは以下の通りです。
- コンポーネント内に重複するロジック(状態管理や副作用など)があるかを見つける。
- そのロジックを新しいJavaScript関数として切り出す。
- 関数名を
use
から始める名前にする。 - 切り出した関数内で、元のコンポーネントで使用していた標準フック(
useState
,useEffect
など)を呼び出す。 - 切り出した関数が必要な状態の値、更新関数、その他のロジックを返すようにする。
- 元のコンポーネントで、切り出したカスタムフックを呼び出し、返り値を使ってロジックを実現する。
例1:シンプルなカウンタロジックの抽出(useState
のみ)
前述の useToggle
は useState
を使ったシンプルな例でしたが、ここではもう少しステップを追って見てみましょう。
元のコンポーネント(例: カウンター):
“`jsx
// SimpleCounter.jsx
import React, { useState } from ‘react’;
function SimpleCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count – 1);
const reset = () => setCount(0);
return (
カウント: {count}
);
}
“`
このコンポーネントには、count
という状態と、それを操作する increment
, decrement
, reset
という関数があります。もし他のコンポーネントでも全く同じカウンター機能が必要になった場合、このロジックをカスタムフックとして切り出すことができます。
カスタムフックの作成 (useCounter.js
):
“`javascript
// useCounter.js
import { useState } from ‘react’;
function useCounter(initialValue = 0) { // 関数名を use から始める
const [count, setCount] = useState(initialValue); // useState を呼び出す
// カウントを増やす関数
const increment = () => {
setCount(prevCount => prevCount + 1); // setState の関数形式を使うと安全
};
// カウントを減らす関数
const decrement = () => {
setCount(prevCount => prevCount – 1);
};
// カウントをリセットする関数
const reset = () => {
setCount(initialValue);
};
// コンポーネントが必要とする状態と関数をオブジェクトで返す
return {
count,
increment,
decrement,
reset,
};
}
export default useCounter;
“`
カスタムフックを使用するコンポーネント (SimpleCounterWithHook.jsx
):
“`jsx
// SimpleCounterWithHook.jsx
import React from ‘react’;
import useCounter from ‘./useCounter’; // カスタムフックをインポート
function SimpleCounterWithHook() {
// カスタムフックを呼び出し、返り値を受け取る
const { count, increment, decrement, reset } = useCounter(10); // 初期値を渡すことも可能
return (
カウント: {count}
);
}
“`
これで、カウンターのロジックが useCounter
というカスタムフックとして切り出され、SimpleCounterWithHook
コンポーネント内で利用できるようになりました。他のコンポーネントでも同じ useCounter
フックを呼び出すことで、簡単にカウンター機能を実装できます。
例2:ウィンドウサイズ取得のロジック抽出(useState
+ useEffect
)
次に、useEffect
を含むロジックをカスタムフックとして切り出す例を見てみましょう。コンポーネントのマウント時にイベントリスナーを設定し、アンマウント時に解除するような副作用を含むロジックです。
元のコンポーネント(前述の WindowSizeDisplay.jsx
):
“`jsx
// WindowSizeDisplay.jsx
import React, { useState, useEffect } from ‘react’;
function WindowSizeDisplay() {
const [windowWidth, setWindowWidth] = useState(window.innerWidth); // 状態
useEffect(() => { // 副作用
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize); // イベントリスナー設定
return () => { // クリーンアップ
window.removeEventListener('resize', handleResize); // イベントリスナー解除
};
}, []); // 依存配列
return (
ウィンドウ幅: {windowWidth}px
);
}
“`
このロジックをカスタムフックとして切り出します。
カスタムフックの作成 (useWindowSize.js
):
“`javascript
// useWindowSize.js
import { useState, useEffect } from ‘react’;
function useWindowSize() { // 関数名を use から始める
const [windowSize, setWindowSize] = useState({ // 状態をオブジェクトで管理
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => { // useEffect を呼び出す
// ウィンドウサイズ変更をハンドリングする関数
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
// イベントリスナーを設定
window.addEventListener('resize', handleResize);
// クリーンアップ関数を返す
return () => {
// イベントリスナーを解除
window.removeEventListener('resize', handleResize);
};
}, []); // 依存配列が空なので、マウント時に一度だけ実行、アンマウント時にクリーンアップ
// ウィンドウサイズの状態を返す
return windowSize;
}
export default useWindowSize;
“`
カスタムフックを使用するコンポーネント (ResponsiveComponent.jsx
):
“`jsx
// ResponsiveComponent.jsx
import React from ‘react’;
import useWindowSize from ‘./useWindowSize’; // カスタムフックをインポート
function ResponsiveComponent() {
// カスタムフックを呼び出し、ウィンドウサイズの状態を取得
const windowSize = useWindowSize();
// ウィンドウ幅に基づいて表示内容を切り替える例
const isMobile = windowSize.width < 768;
return (
レスポンシブコンポーネント
現在のウィンドウ幅: {windowSize.width}px
現在のウィンドウ高さ: {windowSize.height}px
{isMobile ? (
これはモバイル表示です。
) : (
これはデスクトップ表示です。
)}
);
}
“`
これで、ウィンドウサイズを取得し、それに応じて状態を更新するというロジックが useWindowSize
カスタムフックとして分離されました。このフックを使えば、様々なコンポーネントで現在のウィンドウサイズを簡単に取得し、レスポンシブな表示を実装できます。イベントリスナーの設定と解除といった副作用の管理も、フック内部に隠蔽されています。
実践的なカスタムフックの例
ここからは、実際のアプリケーション開発でよく使われるであろう、より実践的なカスタムフックの例をいくつか見ていきましょう。
例3:データの取得 (useFetch
)
APIからデータを取得し、ローディング状態やエラー状態も管理するロジックは、多くのアプリケーションで共通して必要になります。これをカスタムフックとして実装してみましょう。
このフックでは、以下の状態を管理します。
data
: 取得したデータloading
: データ取得中かどうかを示すブール値error
: データ取得中に発生したエラー
カスタムフックの作成 (useFetch.js
):
“`javascript
// useFetch.js
import { useState, useEffect } from ‘react’;
// データ取得関数 (ここでは fetch API を使用)
async function fetchData(url) {
const response = await fetch(url);
if (!response.ok) {
// HTTPエラーレスポンスの場合
throw new Error(HTTP error! status: ${response.status}
);
}
const data = await response.json();
return data;
}
function useFetch(url) { // 引数としてデータの取得元URLを受け取る
const [data, setData] = useState(null); // 取得したデータ
const [loading, setLoading] = useState(true); // ローディング状態
const [error, setError] = useState(null); // エラー状態
useEffect(() => {
// fetch は副作用なので useEffect 内で実行
setLoading(true); // リクエスト開始前にローディング状態をtrueにする
setError(null); // エラー状態をリセット
fetchData(url) // 定義したデータ取得関数を呼び出す
.then(result => {
setData(result); // 成功したらデータをセット
})
.catch(err => {
setError(err); // エラーが発生したらエラー状態をセット
})
.finally(() => {
setLoading(false); // リクエスト完了後にローディング状態をfalseにする (成功・失敗どちらでも)
});
// ★ 重要: コンポーネントがアンマウントされた後にステートを更新しようとするとエラーになる可能性があるため、
// AbortController を使ってフェッチ処理をキャンセルできるようにする(より堅牢な実装の場合)
// ここではシンプルにするため省略しますが、実運用では考慮が必要です。
// もしくは、isMounted のようなフラグを管理する方法もあります。
// 例えば、AbortControllerを使う場合は以下のようなコードを追加
// const abortController = new AbortController();
// const signal = abortController.signal;
// fetchData(url, { signal }) // fetch関数のオプションにsignalを渡す
// .then(...)
// .catch(err => {
// if (err.name === 'AbortError') {
// console.log('Fetch aborted'); // アンマウントによるキャンセル
// } else {
// setError(err);
// }
// })
// .finally(() => {
// setLoading(false);
// });
// return () => abortController.abort(); // クリーンアップでキャンセル
}, [url]); // 依存配列に url を含めることで、url が変更されたときに再フェッチを行う
// データ、ローディング状態、エラー状態をオブジェクトとして返す
return { data, loading, error };
}
export default useFetch;
“`
カスタムフックを使用するコンポーネント (UserDataDisplay.jsx
):
“`jsx
// UserDataDisplay.jsx
import React from ‘react’;
import useFetch from ‘./useFetch’; // カスタムフックをインポート
function UserDataDisplay({ userId }) {
// カスタムフックを呼び出し、ユーザーデータのURLを渡す
// 例: JSONPlaceholder の Users API
const { data: user, loading, error } = useFetch(https://jsonplaceholder.typicode.com/users/${userId}
);
if (loading) {
return
ユーザーデータを読み込み中…
;
}
if (error) {
return
ユーザーデータの取得に失敗しました: {error.message}
;
}
// データが取得できたら表示
return (
ユーザー情報
{user ? ( // user が null でないことを確認
<>
名前: {user.name}
メール: {user.email}
ウェブサイト: {user.website}
) : (
ユーザーデータがありません。
)}
);
}
// このコンポーネントを App.js などで呼び出す例
//
//
“`
これで、URLを渡すだけでデータ取得、ローディング、エラーの管理をまとめて行ってくれる useFetch
カスタムフックが完成しました。このフックを使えば、データの表示を担当するコンポーネントのコードが大幅にシンプルになります。
例4:フォーム入力の管理 (useForm
)
フォームの入力値やその変更、送信処理を管理するロジックも、カスタムフックとして抽象化するのに適しています。
このフックでは、以下の状態と関数を管理します。
values
: 各入力フィールドの値を持つオブジェクトhandleChange
: input, select, textarea 要素のonChange
イベントハンドラーhandleSubmit
: フォームのonSubmit
イベントハンドラー(コールバック関数を受け取る)- (オプション)
errors
: 入力値のバリデーションエラーを持つオブジェクト - (オプション)
isSubmitting
: 送信処理中かどうかを示すブール値
ここではシンプルに、入力値の管理と変更ハンドラー、送信ハンドラーを実装します。
カスタムフックの作成 (useForm.js
):
“`javascript
// useForm.js
import { useState } from ‘react’;
// initialValues は { fieldName: initialValue, … } の形式を想定
function useForm(initialValues = {}) {
const [values, setValues] = useState(initialValues);
// 後々バリデーションを追加するなら useState(initialErrors) なども追加
// 入力値が変更されたときのハンドラー
// イベントオブジェクトを受け取り、name属性とvalue属性を使って状態を更新
const handleChange = (event) => {
// イベントから name (フィールド名) と value (入力値) を取得
// チェックボックスやラジオボタンの場合は event.target.checked を使うなど、型に応じた処理が必要になることも
const { name, value } = event.target;
setValues({
…values, // 現在の値をコピー
[name]: value, // 変更されたフィールドの値を更新
});
};
// フォームが送信されたときのハンドラー
// onSubmit コールバック関数を受け取り、フォームのデフォルト動作を防ぎ、コールバックを実行する
const handleSubmit = (onSubmitCallback) => (event) => {
event.preventDefault(); // フォームのデフォルト送信動作を防ぐ (ページ遷移など)
// ここでバリデーションロジックを実行することも可能
// if (validate(values)) { … }
// 受け取ったコールバック関数に入力値を渡して実行
// コールバック内でAPI送信などの非同期処理を行うことを想定
onSubmitCallback(values);
// 送信後にフォームをリセットしたい場合は setValues(initialValues); など
};
// 入力値の状態、変更ハンドラー、送信ハンドラーをオブジェクトで返す
return {
values,
handleChange,
handleSubmit,
// 後々バリデーションエラーや送信中状態などを追加するならここに追加
// errors,
// isSubmitting,
};
}
export default useForm;
“`
カスタムフックを使用するコンポーネント (ContactForm.jsx
):
“`jsx
// ContactForm.jsx
import React from ‘react’;
import useForm from ‘./useForm’; // カスタムフックをインポート
function ContactForm() {
// useForm フックを呼び出し、初期値を設定
const { values, handleChange, handleSubmit } = useForm({
name: ”,
email: ”,
message: ”,
});
// フォーム送信時の処理を定義する関数
const submitForm = (formValues) => {
console.log(‘フォームが送信されました:’, formValues);
// ここでAPIにデータを送信するなどの処理を行う
alert(‘フォームを送信しました!コンソールを確認してください。’);
};
// handleSubmit に submitForm 関数を渡す
const onSubmit = handleSubmit(submitForm);
return (
);
}
“`
これで、フォームの入力値の状態管理と基本的なイベントハンドリングを useForm
カスタムフックに集約できました。複数のフォームを持つアプリケーションで、同様のロジックを簡単に再利用できるようになります。バリデーションロジックや送信中の状態などもこのフック内に追加していくことで、より高機能なフォーム管理フックに育てることができます。
カスタムフックのルールと注意点
カスタムフックを使う上で、守るべきルールといくつかの注意点があります。これらを理解しないと、予期せぬエラーやバグの原因となることがあります。
React Hooks のルールを理解する
カスタムフックは、React の標準フックを内部で呼び出すため、Hooks 自体に課せられている以下の2つのルールを必ず守る必要があります。
-
トップレベルでだけ Hooks を呼び出すこと
- ループ (
for
,while
) や条件分岐 (if
,else
) の中では Hooks を呼び出してはいけません。 - ネストされた関数の中や、クラスコンポーネントの中では Hooks を呼び出してはいけません。
- Hooks は、React の関数コンポーネント、または他のカスタムフックのトップレベルでのみ呼び出すことができます。
- なぜか?: React は、Hooks がコンポーネントのレンダーごとに常に同じ順序で呼び出されることに依存して、複数の
useState
やuseEffect
呼び出しの状態を内部的に管理しています。条件分岐やループの中で呼び出すと、レンダーごとに呼び出される Hooks の数が変わったり、順序が入れ替わったりしてしまい、React がどの状態やエフェクトがどの呼び出しに対応するものかを判断できなくなります。
- ループ (
-
React の関数コンポーネントまたはカスタムフックの中からだけ Hooks を呼び出すこと
- Hooks は普通の JavaScript 関数ではありますが、上記のルール1を満たすために、呼び出せる場所が限定されています。
カスタムフックが use
から始まる名前を持つのは、React のリンタープラグイン(ESLint の eslint-plugin-react-hooks
)がこれらのルールをチェックし、違反している場合に警告やエラーを出すために利用するためです。このリンターは必須ではありませんが、Hooks を使う開発では導入することを強く推奨します。
依存配列の重要性
useEffect
や useCallback
、useMemo
といったフックには、第二引数として「依存配列」を指定します。カスタムフックの中でこれらのフックを使う場合も、依存配列の扱いは非常に重要です。
- 依存配列の役割:
useEffect
に渡した副作用関数は、依存配列内の値が前回のレンダー時と比べて変更された場合にのみ再実行されます。useCallback
やuseMemo
も同様に、依存配列の値が変更された場合にのみメモ化された関数や値を再計算します。 - 依存配列の省略: 依存配列を省略すると、コンポーネントがレンダーされるたびに毎回エフェクトやメモ化が実行されます。これはパフォーマンスの低下や、意図しない動作につながる可能性があります。
- 依存配列が空(
[]
): エフェクトやメモ化は、コンポーネントのマウント時に一度だけ実行され、その後は依存配列内の値が変わらないため再実行されません。useEffect
の場合、クリーンアップ関数はアンマウント時に実行されます。これは初期化処理などに使われます。 - 依存配列に含めるべきもの: エフェクトやメモ化の関数内で使用している、コンポーネントの Props や State、またはそれらから派生した値は、原則として全て依存配列に含める必要があります。これは、Hooks の世界では、Props や State はレンダーごとに新しい値として扱われるためです。含め忘れると、古い値(クロージャー)を参照してしまう「古いクロージャー問題」が発生する可能性があります。
useFetch
の例では、useEffect
の依存配列に [url]
を含めました。これは、url
という値が変わったときに、エフェクト(データフェッチ)を再実行する必要があるからです。もし url
を含め忘れると、コンポーネントの Props などで userId
が変更されても、フック内の useEffect
は再実行されず、最初の url
のデータしか取得しない、というバグが発生します。
ほとんどの場合、Hooks の依存配列に何を含めるべきか迷ったら、リンターの警告に従うのが最も安全です。リンターは、エフェクト内で使われている外部スコープの変数の中で、依存配列に含まれていないものを教えてくれます。
フック内で他のフックを呼び出す
カスタムフックは、標準フックだけでなく、他のカスタムフックを内部で呼び出すことができます。これは、複数の小さなカスタムフックを組み合わせて、より複雑なロジックを持つカスタムフックを作成できることを意味します。
例えば、useFetch
カスタムフックの中で useWindowSize
フックを呼び出し、「ウィンドウサイズが変わったらデータを再フェッチする」といったロジックを持つ新しいカスタムフックを作成することも理論上は可能です(ただし、この例はあまり現実的ではないかもしれませんが)。
重要なのは、カスタムフックの内部であれば、フックのルールを守りながら他のフックを自由に呼び出せるということです。
ステートは隔離される
カスタムフックが useState
を含む場合、そのカスタムフックを複数のコンポーネントで呼び出すと、各コンポーネントはカスタムフック内のステートの独立したコピーを持つことになります。これは、前述の useToggle
の例で、モーダルとドロップダウンがそれぞれ独自の isOpen
状態を持つのと同じ原理です。
“`jsx
function MyComponent1() {
const { count, increment } = useCounter(); // ここに独自の count が作られる
// …
}
function MyComponent2() {
const { count, increment } = useCounter(100); // ここにまた別の独自の count が作られる
// …
}
“`
MyComponent1
と MyComponent2
はどちらも useCounter
を使っていますが、それぞれの count
の値は完全に独立しています。これは、カスタムフックが「ロジックを共有する」ものであり、「状態そのものを共有する」ものではないことを改めて示しています。
カスタムフックのテスト
作成したカスタムフックが正しく機能するかを確認するために、テストは非常に重要です。特に、副作用(useEffect
)や非同期処理を含むカスタムフックは、ステートの更新が期待通りに行われるか、クリーンアップが正しく機能するかなどを確認する必要があります。
カスタムフックのテストには、@testing-library/react
ライブラリ(Hooks に特化した @testing-library/react-hooks
は @testing-library/react
に統合されました)に含まれる renderHook
というユーティリティ関数を使うのが一般的です。
renderHook
は、Hooks をテスト用の環境で実行し、そのフックが返す値や、フック内部の状態変化などを監視できるようにします。
@testing-library/react
を使ったテストの基本
まず、必要なライブラリをインストールします。
“`bash
npm install –save-dev @testing-library/react @testing-library/jest-dom jest
または yarn
yarn add –dev @testing-library/react @testing-library/jest-dom jest
“`
Jest などのテストランナーの設定も必要ですが、Create React App や Next.js などのフレームワークを使っている場合は最初から設定されていることが多いです。
テストの基本的な流れは以下の通りです。
renderHook
を使ってカスタムフックをレンダーする。renderHook
が返すresult
オブジェクトを通じて、フックの現在の返り値 (result.current
) を参照する。- 必要に応じて、
act
ヘルパーを使って、ステート更新や副作用の発生をトリガーするイベント(ボタンクリックなど)をシミュレートする。act
は、React が DOM の更新を完了するのを待ってくれるため、テストの信頼性が向上します。Hooks のステート更新や副作用も、act
の中で発生させるようにします。 - 期待される結果と
result.current
の値を比較する。
例:useCounter
カスタムフックのテスト
useCounter
フック(前述の例)をテストしてみましょう。
“`javascript
// useCounter.test.js
import { renderHook, act } from ‘@testing-library/react’;
import useCounter from ‘./useCounter’;
describe(‘useCounter’, () => {
// 初期値のテスト
test(‘初期値が0であること(引数なしの場合)’, () => {
const { result } = renderHook(() => useCounter());
// result.current にはフックの返り値(オブジェクト)が入っている
expect(result.current.count).toBe(0);
});
test(‘初期値が指定した値であること’, () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
// increment 関数のテスト
test(‘increment 関数でカウントが増加すること’, () => {
const { result } = renderHook(() => useCounter(5)); // 初期値5で開始
// act ヘルパーを使ってステート更新を伴う処理を実行
act(() => {
result.current.increment(); // increment 関数を呼び出す
});
// act の処理が完了した後に、更新されたステート(フックの返り値)を確認
expect(result.current.count).toBe(6);
// もう一度 increment を呼び出す
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(7);
});
// decrement 関数のテスト
test(‘decrement 関数でカウントが減少すること’, () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
// reset 関数のテスト
test(‘reset 関数で初期値に戻ること’, () => {
const { result } = renderHook(() => useCounter(10));
// まず increment で値を変更
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(11);
// reset を呼び出す
act(() => {
result.current.reset();
});
// 初期値である 10 に戻っているか確認
expect(result.current.count).toBe(10);
});
test(‘reset 関数でデフォルトの初期値(0)に戻ること’, () => {
const { result } = renderHook(() => useCounter()); // 初期値なし(デフォルト0)
// 値を増加
act(() => {
result.current.increment(); // count becomes 1
result.current.increment(); // count becomes 2
});
expect(result.current.count).toBe(2);
// reset を呼び出す
act(() => {
result.current.reset();
});
// デフォルトの初期値である 0 に戻っているか確認
expect(result.current.count).toBe(0);
});
});
“`
このテストコードでは、renderHook
を使って useCounter
フックを実行し、返される result
オブジェクト(特に result.current
)を通してフックの状態や関数にアクセスしています。ステートを更新する increment
, decrement
, reset
関数を呼び出す際には、必ず act
ヘルパーで囲んでいます。これにより、React がこれらの操作によるステート更新を処理し終えるのを待ってからアサーション(expect
による検証)を実行できるため、テストの信頼性が高まります。
useFetch
のテスト(より複雑な例)
useEffect
や非同期処理を含むフックのテストは少し複雑になります。非同期処理の完了を待ったり、エフェクトの実行をシミュレートしたりする必要があるからです。
useFetch
フックのテストでは、モック(模擬オブジェクト)を使って fetch
関数を置き換えるのが一般的です。これにより、実際のネットワークリクエストを行わずにテストを実行できます。
“`javascript
// useFetch.test.js
import { renderHook, act, waitFor } from ‘@testing-library/react’;
import useFetch from ‘./useFetch’;
// グローバルな fetch 関数をモックする
global.fetch = jest.fn();
// モックの response を作成するヘルパー関数
const createFetchResponse = (data) => ({ json: () => new Promise(resolve => resolve(data)) });
describe(‘useFetch’, () => {
// 各テストの前に fetch モックをリセット
beforeEach(() => {
fetch.mockClear();
});
test(‘初期状態でローディング中であること’, () => {
// fetch がまだ解決されない状態をモック
fetch.mockImplementationOnce(() => new Promise(() => {})); // Never resolve
const { result } = renderHook(() => useFetch('test-url'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
});
test(‘データの取得に成功した場合、データとローディング状態が正しく更新されること’, async () => {
const mockData = { id: 1, name: ‘Test User’ };
// fetch が mockData を返すようにモック
fetch.mockImplementationOnce(() =>
Promise.resolve(createFetchResponse(mockData))
);
const { result } = renderHook(() => useFetch('test-url'));
// 初期状態はローディング中
expect(result.current.loading).toBe(true);
// fetch が完了するのを待つ
// act と async/await, waitFor を組み合わせて非同期副作用の完了を待つ
await act(async () => {
// エフェクト内部の非同期処理が完了するのを待つ
// @testing-library/react の waitFor は、指定したコールバックが true を返すまで待機する
// ここでは loading が false になるのを待つことで、fetch 完了を判断
await waitFor(() => expect(result.current.loading).toBe(false));
});
// fetch 完了後の状態を確認
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
expect(fetch).toHaveBeenCalledWith('test-url'); // 正しいURLでfetchが呼ばれたか確認
});
test(‘データの取得に失敗した場合、エラー状態が正しく更新されること’, async () => {
const mockError = new Error(‘Failed to fetch’);
// fetch がエラーを投げるようにモック
fetch.mockImplementationOnce(() => Promise.reject(mockError));
const { result } = renderHook(() => useFetch('test-url'));
// 初期状態はローディング中
expect(result.current.loading).toBe(true);
// fetch が完了するのを待つ (エラー発生も含む完了)
await act(async () => {
await waitFor(() => expect(result.current.loading).toBe(false));
});
// エラー発生後の状態を確認
expect(result.current.loading).toBe(false);
expect(result.current.data).toBe(null);
expect(result.current.error).toEqual(mockError);
expect(fetch).toHaveBeenCalledWith('test-url'); // 正しいURLでfetchが呼ばれたか確認
});
test(‘URLが変更されたときに再フェッチすること’, async () => {
const mockData1 = { id: 1, name: ‘User 1’ };
const mockData2 = { id: 2, name: ‘User 2’ };
// 最初のfetchは mockData1 を返すようにモック
fetch.mockImplementationOnce(() =>
Promise.resolve(createFetchResponse(mockData1))
);
const { result, rerender } = renderHook(
({ url }) => useFetch(url), // 引数urlを受け取るように修正
{ initialProps: { url: 'url-1' } } // 初期Propsとして url を渡す
);
// 最初のfetchが完了するのを待つ
await act(async () => {
await waitFor(() => expect(result.current.loading).toBe(false));
});
expect(result.current.data).toEqual(mockData1);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('url-1');
// 2回目のfetchは mockData2 を返すようにモック
fetch.mockImplementationOnce(() =>
Promise.resolve(createFetchResponse(mockData2))
);
// renderHook に新しい props を渡してフックを再レンダーする(url が変わる)
await act(async () => {
rerender({ url: 'url-2' }); // 新しいURLで再レンダーをトリガー
// 再フェッチが開始され、ローディング状態が true になるのを待つ
await waitFor(() => expect(result.current.loading).toBe(true));
// 再フェッチ完了でローディング状態が false になるのを待つ
await waitFor(() => expect(result.current.loading).toBe(false));
});
// 2回目のfetchが完了後の状態を確認
expect(result.current.data).toEqual(mockData2);
expect(fetch).toHaveBeenCalledTimes(2); // fetchが合計2回呼ばれたか確認
expect(fetch).toHaveBeenCalledWith('url-2'); // 新しいURLでfetchが呼ばれたか確認
});
// AbortController を使った場合のクリーンアップ処理のテストもここに追加可能
// (例:コンポーネントがアンマウントされたときに fetch がキャンセルされるか)
});
“`
非同期処理を含むカスタムフックのテストは、Promise の解決やエラーを待ったり、useEffect
の実行タイミングを考慮したりする必要があるため、より高度になります。act
と waitFor
を適切に使うことで、非同期なステート更新や副作用の完了を待つことができます。
最初はシンプルなフックからテストを始めて、慣れてきたら非同期処理を含む複雑なフックのテストに挑戦するのが良いでしょう。
カスタムフックの設計パターンとベストプラクティス
効果的なカスタムフックを作成するためには、いくつかの設計パターンやベストプラクティスを意識することが重要です。
単一責任の原則
一つのカスタムフックは、一つの明確なロジックの責任を持つように設計しましょう。例えば、「データの取得」と「フォーム入力の管理」という全く異なるロジックを一つのフックに詰め込むべきではありません。
useFetch
、useForm
、useWindowSize
のように、それぞれのフックが独立した明確な機能を持つことで、再利用性が高まり、コードが理解しやすくなります。もし複数のロジックを組み合わせたい場合は、それぞれのロジックを担当する小さなカスタムフックを、別の大きなカスタムフックの中で呼び出す、というようにフックを組み合わせることを検討しましょう。
命名規則
カスタムフックの名前は、必ず use
から始め、その後にフックの機能を表す名前をPascalCase(先頭大文字のラクダケース)で続けます。例えば、useUserData
、useCounter
、useForm
のようにです。
この規則に従うことで、開発者はその関数がカスタムフックであり、Hooks のルールに従う必要があることをすぐに認識できます。
引数と戻り値の構造化
カスタムフックが受け取る引数や返す値は、分かりやすく構造化しましょう。
- 引数: 設定値、初期値、コールバック関数など、フックの振る舞いをカスタマイズするための引数を受け取ります。引数が多い場合は、設定オブジェクトとして渡すのが一般的です(例:
useFetch(url, options)
)。 - 戻り値: コンポーネントがロジックを利用するために必要な、状態の値、状態を更新する関数、副作用を実行する関数などを返します。返すものが複数の場合は、オブジェクトまたは配列として返すのが一般的です。
- 配列で返す場合: 返す要素の順序が重要になります(例:
useState
の[value, setValue]
)。要素名が固定でない場合や、要素が増える可能性がある場合は、配列は読みにくくなることがあります。 - オブジェクトで返す場合: 返す要素の名前でアクセスできるため、順序を気にする必要がなく、新しい要素を追加しても既存のコードに影響を与えにくいです。一般的にはオブジェクトで返す方が柔軟で分かりやすいことが多いです(例:
useCounter
の{ count, increment, ... }
)。特に、フックの返り値が多い場合や、返す要素の順序が任意である場合はオブジェクトが推奨されます。
- 配列で返す場合: 返す要素の順序が重要になります(例:
ドキュメンテーション
作成したカスタムフックが他の開発者(未来の自分も含む)にとって使いやすいように、適切にドキュメンテーションを行いましょう。JSDoc形式で、フックの目的、引数の説明、戻り値の説明などを記述すると良いでしょう。
javascript
/**
* 現在のウィンドウサイズを取得するカスタムフック
* @returns {{width: number, height: number}} ウィンドウの幅と高さを含むオブジェクト
*/
function useWindowSize() {
// ... implementation
}
エラーハンドリングとローディング状態
データフェッチや非同期処理を含むカスタムフックの場合、エラーが発生した場合のハンドリングや、処理中のローディング状態の管理は非常に重要です。useFetch
の例のように、loading
と error
という状態をフックの返り値に含めることで、フックの利用者(コンポーネント)はこれらの状態に基づいて適切なUI(ローディングスピナーの表示、エラーメッセージの表示など)をレンダリングできます。
状態管理ライブラリとの使い分け
グローバルな状態管理(アプリケーション全体で共有される状態)が必要な場合は、Context API、Redux、Zustand、Recoil などの状態管理ライブラリの使用を検討します。カスタムフックは、特定のコンポーネントや、そのサブツリー内でロジックを再利用するのに適しています。
もしカスタムフック内で管理している状態が、複数のコンポーネントインスタンス間で共有されるべきもの(例: ログインユーザー情報、カートの中身)であれば、その状態はカスタムフックではなく、Context API や状態管理ライブラリを使って管理し、カスタムフックはその状態にアクセスするためのインターフェースを提供する、といった役割分担をすることもできます。
カスタムフックの応用例とエコシステム
カスタムフックは、単に簡単なロジックを抽出するだけでなく、様々な応用が可能です。
複雑なロジックの抽象化
認証の状態管理、WebSocket通信の処理、アニメーションの制御、デバイスのセンサー情報へのアクセスなど、比較的複雑なロジックもカスタムフックとして抽象化できます。これにより、これらの機能を使うコンポーネントは、複雑な実装の詳細を知る必要がなくなり、より宣言的にコードを書けるようになります。
ライブラリとして公開されている便利なカスタムフック
React コミュニティでは、多くの便利なカスタムフックがライブラリとして公開されています。
react-use
: センサー、UI、副作用、状態管理など、多岐にわたるフックを提供。ahooks
: Ant Design チームが開発している、堅牢で実用的なフック集。データフェッチ、ライフサイクル、状態管理など。react-query
/swr
: データフェッチに特化したライブラリですが、強力なカスタムフック (useQuery
,useSWR
) を提供し、キャッシュ、同期、エラー処理などを簡単に行えます。
これらのライブラリが提供するフックは、カスタムフックの強力さを示す良い例であり、自分でカスタムフックを作成する際の参考にもなります。また、既製のフックでやりたいことが実現できる場合は、それらを活用することも賢明です。
ライブラリが提供するフック
Reactルーターの useLocation
, useParams
, useNavigate
、GraphQLクライアント(Apollo Client, Relay Modern)の useQuery
, useMutation
など、多くのライブラリがHooks APIを提供しています。これらも概念的にはカスタムフックであり、特定の機能(ルーティング情報、データフェッチなど)へのアクセスや操作を、コンポーネントから Hooks のルールに従って行えるようにしています。
このように、カスタムフックの概念は React エコシステム全体に広く浸透しており、React で効率的に開発を進める上で不可欠な要素となっています。
まとめ
この記事では、React のカスタムフックについて、初心者向けに以下の内容を詳しく解説しました。
- カスタムフックとは何か: React の標準フックを呼び出す
use
から始まる関数であり、コンポーネント間で状態ロジックを共有するための仕組みであること。UIは返さないこと。 - なぜカスタムフックを使うのか: ロジックの再利用、コンポーネントからの関心分離、可読性・保守性・テスト容易性の向上といったメリットがあること。
- カスタムフックの作り方: コンポーネント内のロジックを切り出し、
use
から始まる関数にし、中で標準フックを呼び出し、必要な値を返す、という基本的な手順。useState
やuseEffect
を含む具体的な例を通して作成方法を学びました。 - 実践的なカスタムフックの例: 実際の開発で役立つ
useFetch
(データ取得)、useForm
(フォーム管理)といった具体的なカスタムフックの実装例を見ました。 - カスタムフックのルールと注意点: Hooks の2つのルール(トップレベルでの呼び出し、関数コンポーネント/カスタムフック内での呼び出し)を必ず守る必要があること、依存配列の重要性、ステートが各コンポーネントで隔離されることなどを理解しました。
- カスタムフックのテスト方法:
@testing-library/react
のrenderHook
を使ってフックをテストする基本的な方法と、非同期処理を含む場合のテスト方法を学びました。act
の重要性も確認しました。 - カスタムフックの設計パターンとベストプラクティス: 単一責任、命名規則、引数と戻り値の構造化、ドキュメンテーション、エラーハンドリング、状態管理ライブラリとの使い分けといった、より良いカスタムフックを作成するための指針について触れました。
- カスタムフックの応用とエコシステム: 複雑なロジックの抽象化や、既存の便利なカスタムフックライブラリについて紹介しました。
カスタムフックは、React コンポーネントをよりシンプルに、より再利用可能に、そしてより保守しやすくするための非常に強力なツールです。最初は少し難しく感じるかもしれませんが、まずは既存のコンポーネント内の重複したロジックを小さなカスタムフックとして切り出すことから始めてみましょう。
この記事で学んだ知識と具体的な例を参考に、ぜひご自身のプロジェクトでカスタムフックを活用してみてください。きっと、React 開発がより効率的で楽しいものになるはずです。
これで、カスタムフックへの第一歩を踏み出せましたね。今後の学習の旅を楽しんでください!