React(およびNext.js)開発におけるユーティリティ関数(Utils)とHooksの基本的な概念、メリット・デメリット、どのような場面でどちらを使うべきかについて解説します。また、再利用性を理由に「なんでもHooksにしてしまう」場合のリスクや注意点についても触れていきます。
1. 基本的な概念
まず、UtilsとHooksという2つの概念について明確に理解しましょう。この理解が、適切に使い分ける基盤となります。
1.1 ユーティリティ関数 (Utils) とは
ユーティリティ関数は、アプリケーション全体で再利用可能な純粋なJavaScript関数です。Reactの機能に依存せず、データの変換、検証、計算などを担当します。
特徴:
- 純粋関数: 同じ入力に対して常に同じ出力を返す。
- 副作用がない: 外部の状態を変更せず、シンプルな処理に特化。
- 再利用性: コンポーネント間はもちろん、サーバーサイドの処理(SSRやAPIルート)でも利用可能。
- テストしやすい: 入力と出力が明確なため、単体テストが容易。
一般的な例:
- 日付や数値のフォーマット(例:
formatDate
、formatCurrency
) - バリデーションやデータ変換(例:文字列操作、配列操作、オブジェクトの抽出)
1.2 Hooks とは
Hooksは、React 16.8以降に導入された仕組みで、関数コンポーネントに状態管理や副作用処理を導入するためのAPIです。標準のuseState
、useEffect
などに加え、カスタムフックとしてロジックを共通化できるため、UIとは直接関係のない処理を分離するのに適しています。
特徴:
-
状態管理:
useState
やuseReducer
を使ってコンポーネントの状態を管理。 -
副作用の管理:
useEffect
でデータフェッチやイベントリスナーの設定などを行う。 - 再利用可能なロジック: カスタムフックとして、UI要素なしで共通ロジックを抽出可能。
- Reactのライフサイクルにアクセス: マウント・アンマウント時の処理や依存性管理が容易。
2. 使い分けの基準とユースケース
React/Next.jsのプロジェクトでは、UtilsとHooksのどちらを使うべきかを明確にする判断基準が重要です。ここでは、選択の指針となるポイントとユースケースを紹介します。
2.1 判断基準
選択にあたっては、以下の観点から判断すると良いでしょう:
-
ロジックがReactの機能に依存するか?
- 状態管理や副作用が必要な場合: カスタムフックとして実装する。
- 純粋な計算・変換のみの場合: ユーティリティ関数として実装する。
-
実行する文脈はReactコンポーネント内か?
- コンポーネント内で実行する場合はHooksを利用。
- コンポーネント外(APIルートやSSR)でも再利用する場合はユーティリティ関数が適している。
-
ロジックの再利用性と複雑さ:
- 複数のコンポーネントで同じ状態管理や副作用処理が必要な場合はカスタムフック。
- 単純な変換や計算はユーティリティ関数で十分。
-
メンテナンス性とテストのしやすさ:
- 純粋関数としてのユーティリティは単体テストが簡単。
- HooksはReact環境でのテストが必要となるため、必要な場合にのみカスタムフックにする。
2.2 具体的なユースケース
それぞれのケースで、どちらを採用すべきかの具体例を以下に示します:
-
データ整形・フォーマット:
- 使うべきもの: 純粋な変換処理はユーティリティ関数
-
例:
formatDate()
やformatCurrency()
-
API呼び出し:
- 使うべきもの: APIリクエスト自体はユーティリティ関数、取得後の状態管理はカスタムフック
-
例:
fetchUserData()
とuseFetchUser()
-
状態管理:
-
使うべきもの: 複雑な状態更新は
useReducer
とリデューサー関数(ユーティリティ関数として切り出し)
-
使うべきもの: 複雑な状態更新は
-
イベント処理やウィンドウサイズの監視:
-
使うべきもの: カスタムフック(例:
useWindowSize()
)を使用し、UIに反映
-
使うべきもの: カスタムフック(例:
3. 実際のコード例
ここでは、前述の概念や基準に基づいた具体的なコード例を紹介します。それぞれの実装パターンを実際のコードで見ることで、理解が深まるでしょう。
3.1 ユーティリティ関数のサンプル
以下は、日付のフォーマットなどの変換処理を行うユーティリティ関数の例です:
// utils/format-utils.js
export const formatDate = (date, format = 'YYYY-MM-DD') => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day);
};
export const formatNumber = (num, locale = 'ja-JP') => {
return new Intl.NumberFormat(locale).format(num);
};
また、オブジェクトの操作用関数も以下のように定義できます:
// utils/object-utils.js
export const pickProps = (obj, keys) => {
return keys.reduce((acc, key) => {
if (obj.hasOwnProperty(key)) {
acc[key] = obj[key];
}
return acc;
}, {});
};
3.2 API通信の適切な設計:Utilsとカスタムフックの連携
API通信の処理は、特に適切な設計が重要です。「基本的なAPI通信はユーティリティ関数として実装し、Reactのライフサイクルに組み込む必要がある場合はそれをHooksでラップする」というのが1つのアプローチです。これにより、クライアントコンポーネントとサーバーコンポーネントの両方で同じ関数を再利用できます。
3.2.1 APIクライアントのユーティリティ関数
まず、基本的なAPI通信処理をユーティリティ関数として実装します:
// utils/api-client.js
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com';
// 基本的なFetch関数
export const fetchApi = async (endpoint, options = {}) => {
const url = `${BASE_URL}${endpoint}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
const error = new Error(`API error: ${response.status}`);
error.status = response.status;
error.statusText = response.statusText;
throw error;
}
return response.json();
};
// 具体的なAPI呼び出し関数(エンドポイントをカプセル化)
export const getUserData = (userId) => {
return fetchApi(`/users/${userId}`);
};
export const getProducts = (category) => {
const endpoint = category
? `/products?category=${encodeURIComponent(category.id)}`
: '/products';
return fetchApi(endpoint);
};
3.2.2 APIユーティリティをラップするカスタムフック
次に、上記のユーティリティ関数をカスタムフックでラップします。エンドポイントは各API関数内でカプセル化されているため、Hooksはより具体的な機能に焦点を当てます:
// hooks/useApi.js
import { useState, useEffect } from 'react';
import { getUserData, getProducts } from '../utils/api-client';
// 特定のAPIに特化したカスタムフック
export const useUserData = (userId) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
if (!userId) {
setLoading(false);
return;
}
try {
setLoading(true);
const result = await getUserData(userId);
setData(result);
setError(null);
} catch (err) {
setError(err);
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
}, [userId]);
return { data, loading, error };
};
// 別のAPI向けのカスタムフック
export const useProducts = (category) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const result = await getProducts(category);
setData(result);
setError(null);
} catch (err) {
setError(err);
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
}, [category]);
return { data, loading, error };
};
3.2.3 その他のカスタムフック例
ウィンドウサイズを監視するような、純粋にクライアントサイドのロジックはカスタムフックとして実装します:
// hooks/useWindowSize.js
import { useState, useEffect } from 'react';
export const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
// 初期サイズを設定
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize;
};
3.3 ユーティリティ関数とカスタムフックの組み合わせ例
以下は、投稿一覧を表示するコンポーネントの例です。ここでは、日付フォーマットなどの純粋な処理はユーティリティ関数で、データ取得や状態管理はカスタムフックで行っています:
// components/PostList.jsx
import { useFetch } from '../hooks/useFetch';
import { formatDate } from '../utils/format-utils';
function PostList() {
const { data: posts, loading, error } = useFetch('/api/posts');
if (loading) return <p>Loading...</p>;
if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
if (!posts || posts.length === 0) return <p>No posts found.</p>;
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<strong>{post.title}</strong>
<span className="date">({formatDate(post.publishedAt)})</span>
</li>
))}
</ul>
);
}
4. HooksとUtilsの混同と誤用パターン
実際のプロジェクトでは、HooksとUtilsの境界が曖昧になり、適切な使い分けができていないケースもあります。ここでは、よくある誤用パターンとその問題点を解説します。
4.1 「なんでもHooks化」する問題
再利用可能なコードをすべてHooks形式で実装してしまう傾向があります。これには以下のような問題があります:
- 不必要な複雑さ: 単純なユーティリティ関数がHooksとして実装されると、React特有のルール(トップレベルでのみ呼び出すなど)に縛られます
- パフォーマンス低下: 単純な計算や変換でもReactのレンダリングサイクルに組み込まれてしまう
- テストの複雑化: 純粋関数よりもHooksのテストは一般的に複雑です
- 誤解を招くAPI: 「use」プレフィックスは状態やライフサイクルに関わるべきなのに、単純な計算に使われる
例えば、次のようなコードは明らかな誤用です:
// 不適切な例
const useFormatCurrency = (amount) => {
// 単なるフォーマット処理なのにHook形式
return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(amount);
};
これは単純に以下のようなユーティリティ関数であるべきです:
// 適切な例
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(amount);
};
4.2 過剰な状態管理
Hooksに慣れると、状態管理が必要ない場面でもuseState
を使いたくなる傾向があります:
// 不適切な例
const ProductCard = ({ product }) => {
// 変更されない値なのに状態として保持している
const [formattedPrice, setFormattedPrice] = useState(formatCurrency(product.price));
return (
<div>
<h3>{product.name}</h3>
<p>{formattedPrice}</p>
</div>
);
};
4.3 Hooksの中に純粋関数を埋め込む
カスタムHooksの中には、本来は分離すべき純粋関数が埋め込まれていることもあります:
// 不適切な例
const useProductData = (productId) => {
const [product, setProduct] = useState(null);
useEffect(() => {
fetchProduct(productId).then(data => {
// この変換ロジックは別のユーティリティ関数にすべき
const transformedData = {
...data,
price: data.price / 100,
isOnSale: data.discount > 0,
fullName: `${data.brand} ${data.name}`
};
setProduct(transformedData);
});
}, [productId]);
return product;
};
5. 適切な切り分け方
UtilsとHooksの適切な使い分けは、メンテナンス性の高いコードベースを構築する上で重要です。
それぞれの特徴と重複してしまう内容は多いですが、切り分け方として意識しておきましょう。
5.1 Utilsを使うべき場面
純粋関数としての性質を持つ処理には、ユーティリティ関数が適しています:
-
純粋な計算や変換
文字列の操作、日付・時間・数値のフォーマット、データの検証、オブジェクトや配列の変換など、入力から出力を純粋に計算するロジックはユーティリティ関数として実装すべきです。 -
Reactコンテキスト外でも使える関数
APIレスポンスの整形、ビジネスロジックの計算、汎用的なヘルパー関数など、React固有の機能に依存しないロジックはユーティリティ関数として切り出すことで、サーバーサイドやテスト環境でも容易に再利用できます。
5.2 Hooksを使うべき場面
Reactの状態やライフサイクルと関連する処理には、Hooksが適しています:
-
コンポーネントの状態管理
ユーザー入力の処理やUIの状態(開閉、選択など)のように、コンポーネントの状態を管理する必要がある場合はHooksを使用します。 -
副作用の処理
イベントリスナーの管理、アニメーション、Reactのライフサイクルに関連する処理など、副作用を伴うロジックはHooksで管理することで、適切なタイミングでの実行と後処理(クリーンアップ)が保証されます。 -
Reactの機能に依存するロジック
コンテキスト値の使用、メモ化(useMemo, useCallback)、レンダリングライフサイクルとの連携など、React特有の機能を利用するロジックはHooksとして実装します。 -
ユーティリティ関数をReactのライフサイクルに組み込む
基本的な処理(例:API通信)は純粋なユーティリティ関数として実装し、それをReactのライフサイクルに組み込みたい場合にHooksでラップします。これにより、サーバーコンポーネントとクライアントコンポーネントの両方で基本的なユーティリティ関数を再利用できます。
6. リファクタリングのアプローチ
HooksとUtilsが混同されている場合、段階的にリファクタリングを行うことを検討するべきです。以下にアプローチの一例を示します。
6.1 リファクタリングの手順
-
Hooksから純粋関数を抽出する
カスタムHooks内に埋め込まれている純粋な変換ロジックや計算を特定し、別のユーティリティ関数として分離します。 -
不要なHooksの除去
状態や副作用を持たないHooksを特定し、純粋なユーティリティ関数に変換します。これにより、呼び出し制約から解放され、より柔軟に使用できるようになります。 -
命名規則の徹底
use
プレフィックスはReactのルールに従うHooksのみに使用し、ユーティリティ関数は適切な動詞(format
,validate
,transform
など)で始めるように統一します。これにより、コードの意図が明確になります。
6.2 実際のリファクタリング例
リファクタリング前:
// リファクタリング前:すべてがHook内にある
const useProductDisplay = (product) => {
// フォーマットはユーティリティ関数にすべき
const formattedPrice = new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(product.price);
// この変換も純粋関数にすべき
const discountRate = product.originalPrice ? (1 - product.price / product.originalPrice) * 100 : 0;
// 実際に状態が必要なのはここだけ
const [isExpanded, setIsExpanded] = useState(false);
return {
formattedPrice,
discountRate: discountRate.toFixed(0) + '%',
isExpanded,
toggleExpand: () => setIsExpanded(!isExpanded)
};
};
リファクタリング後:
// 純粋なユーティリティ関数
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(amount);
};
const calculateDiscountRate = (currentPrice, originalPrice) => {
if (!originalPrice || originalPrice <= 0) return 0;
return (1 - currentPrice / originalPrice) * 100;
};
const formatDiscountRate = (rate) => {
return rate.toFixed(0) + '%';
};
// 状態管理だけを行うHook
const useExpandable = (initialState = false) => {
const [isExpanded, setIsExpanded] = useState(initialState);
return {
isExpanded,
toggleExpand: () => setIsExpanded(!isExpanded)
};
};
// コンポーネント内での使用
const ProductCard = ({ product }) => {
// ユーティリティ関数の使用
const formattedPrice = formatCurrency(product.price);
const discountRate = calculateDiscountRate(product.price, product.originalPrice);
const formattedDiscountRate = formatDiscountRate(discountRate);
// 状態管理のみのHook
const { isExpanded, toggleExpand } = useExpandable();
return (
<div>
<h3>{product.name}</h3>
<p>{formattedPrice}</p>
{discountRate > 0 && <p>割引率: {formattedDiscountRate}</p>}
<button onClick={toggleExpand}>
{isExpanded ? '詳細を隠す' : '詳細を表示'}
</button>
{isExpanded && <div>{product.description}</div>}
</div>
);
};
このリファクタリング例では、純粋な計算をユーティリティ関数に抽出し、状態管理のみをHookで行うことで、コードの責務が明確に分離され、再利用性とテスト容易性が向上しています。
7. サーバーコンポーネントとの相性
Next.js 13以降のApp Routerでは、サーバーコンポーネントがデフォルトとなり、クライアントとサーバーの境界がより明確になりました。この変化により、UtilsとHooksの適切な使い分けがさらに重要になっています。
7.1 サーバーコンポーネントでのユーティリティ関数の活用
サーバーコンポーネントではHooksを使用できないため、純粋なユーティリティ関数の価値が高まります:
// app/products/[id]/page.js (サーバーコンポーネント)
import { getProduct } from '@/utils/api-client';
import { formatCurrency, formatDate } from '@/utils/format-utils';
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>価格: {formatCurrency(product.price)}</p>
<p>発売日: {formatDate(product.releaseDate)}</p>
</div>
);
}
7.2 クライアントコンポーネントでのHooksとUtilsの連携
クライアントコンポーネントでは、ユーティリティ関数とHooksを組み合わせて使用できます:
// components/ProductCard.jsx (クライアントコンポーネント)
'use client';
import { useState } from 'react';
import { formatCurrency } from '@/utils/format-utils';
import { useProductData } from '@/hooks/useApi';
export default function ProductCard({ productId }) {
const { data: product, loading, error } = useProductData(productId);
const [isExpanded, setIsExpanded] = useState(false);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error loading product</div>;
return (
<div>
<h2>{product.name}</h2>
<p>{formatCurrency(product.price)}</p>
<button onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? '詳細を隠す' : '詳細を表示'}
</button>
{isExpanded && <p>{product.description}</p>}
</div>
);
}
7.3 コード共有のベストプラクティス
サーバーとクライアントの両方で同じロジックを使用する場合は、ユーティリティ関数として実装し、必要に応じてHooksでラップするアプローチが最適です:
- 基本機能をユーティリティ関数として実装: API通信、データ変換、ビジネスロジックなど
- クライアント特有のニーズに応じてHooksでラップ: 状態管理や副作用が必要な場合
このアプローチにより、コードの再利用性を最大化しつつ、それぞれの実行環境の特性を活かした最適な設計が可能になります。
8. まとめ
React/Next.jsの開発においてUtilsとHooksは異なる役割を持ち、それぞれの長所を活かすことで、より保守性が高く効率的なコードベースを構築できます。
- ユーティリティ関数(Utils) は純粋関数として再利用可能なロジックを提供し、Reactの機能に依存しない処理に適しています。サーバーコンポーネントを含む幅広い環境で再利用できる点が大きな利点です。
- カスタムHooks はコンポーネントのライフサイクルと状態に関連するロジックをカプセル化し、Reactの機能を活用した再利用可能な処理に適しています。クライアントコンポーネント内でUIと密接に関連する処理に最適です。
「なんでも再利用するからHooks」という考え方ではなく、それぞれの特徴を理解し、適材適所で使い分けることが重要です。
UtilsとHooksの適切な使い分けは、小さなプロジェクトでは些細に思えるかもしれませんが、プロジェクトの規模が大きくなるにつれて、その重要性は増していきます。