react-hooks-composition について
react-hooks-composition は、React と TypeScript 向けのカスタムフック構成パターンを解説するガイドです。SWR による取得、検索のデバウンス、コンテキストの最適化、ステートマシンを扱います。
主な機能
- 01SWR の条件付きデータ取得
- 02二重の読み込み状態を持つ検索デバウンス
- 03メモ化されたコンテキストプロバイダー
- 04型安全なステートマシン
- 05テストしやすい純粋なヘルパー関数
活用シーン
- 01ユーザー入力に応じたデータ取得
- 02検索入力中の API 呼び出し削減
- 03コンテキスト利用側の不要な再レンダリング防止
- 04非同期処理の読み込み・エラー状態の管理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ bobmatnyc/ claude-mpm-skills --skill react-hooks-compositionインストール後、Claude Code で/react-hooks-compositionを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.jsonreferences/performance-optimization.mdswr-patterns.mdtesting-patterns.md詳細
React Hooks Composition Patterns
概要
保守しやすく、高速で、型安全なカスタムフックを作成するための、React フックを合成する高度なパターンです。SWR との統合、デバウンス検索、メモ化されたコンテキスト、ステートマシンパターンについて説明します。
主な概念:
- null キーによる条件付き SWR フェッチ
- 2 種類のローディングインジケーターを備えたデバウンス状態
- 再レンダリングを防ぐメモ化されたコンテキストプロバイダー
- UI 状態を予測可能にするステートマシンパターン
- テスト容易性を高める純粋なヘルパー関数
パターン 1:条件付きフェッチによる SWR フックの合成
パターン
条件分岐、データ変換、純粋なヘルパー関数を組み合わせて、SWR を合成します。
import useSWR from 'swr';
import { useMemo } from 'react';
// Type definitions
interface MapboxSuggestion {
name: string;
mapbox_id: string;
context?: {
country?: {
country_code: string;
};
};
}
interface MapboxSuggestResponse {
suggestions: MapboxSuggestion[];
}
interface LocationSuggestion {
id: string;
displayName: string;
region: string;
}
// Custom hook with conditional fetching
export function useMapboxLocationSuggestions(
inputValue: string | null | undefined
) {
const sessionId = useSessionId();
// Conditional SWR key - null disables fetching
const { data, error, isLoading } = useSWR<MapboxSuggestResponse>(
// Key is null (no fetch) unless all conditions met
sessionId &&
process.env.NEXT_PUBLIC_MAPBOX_API_KEY &&
isValidSearchQuery(inputValue)
? `https://api.mapbox.com/search/searchbox/v1/suggest?q=${encodeURIComponent(inputValue!)}&session_token=${sessionId}&access_token=${process.env.NEXT_PUBLIC_MAPBOX_API_KEY}`
: null
);
// Transform data with useMemo for performance
const mappedData = useMemo(() => {
if (!data) return undefined;
return data.suggestions
.filter(isUsState)
.map(formatMapboxLocation);
}, [data]);
return {
data: mappedData,
error,
isLoading
};
}
// Pure helper functions (outside component/hook)
const isValidSearchQuery = (
value: string | null | undefined
): value is string => {
return typeof value === 'string' && value.trim().length >= 2;
};
const isUsState = (suggestion: MapboxSuggestion): boolean => {
return suggestion.context?.country?.country_code === 'us';
};
const formatMapboxLocation = (
suggestion: MapboxSuggestion
): LocationSuggestion => ({
id: suggestion.mapbox_id,
displayName: suggestion.name,
region: 'US',
});
これが機能する理由
条件付きフェッチ:
- キーが
nullの場合、SWR はフェッチしません - URL を構築する前に、すべての条件を確認します
- 型ガード
isValidSearchQueryにより型安全性が確保されます - 入力が空のときに不要な API 呼び出しが発生するのを防ぎます
データ変換:
useMemoにより、レンダーごとの再計算を防ぎます- 依存配列
[data]により、データが変更された場合にのみ再計算します - filter と map の処理は純粋で、テスト可能です
テスト容易性:
- 純粋関数は個別にテストできます
- ヘルパー関数に React コンテキストは必要ありません
- 型ガードにより、実行時の検証が可能です
よくある使用場面
次のような場合に、このパターンを使用します:
- 「ユーザー入力に基づいてデータを取得する」
- 「SWR で条件付き API 呼び出しを行う」
- 「API レスポンスデータを変換する」
- 「検索が空のときにフェッチを防ぐ」
- 「型安全なデータフェッチフックを作成する」
パターン 2:2 種類のローディング状態を備えたデバウンス検索
パターン
デバウンスされた入力と SWR フェッチを組み合わせ、デバウンス中とネットワーク読み込み中の両方の状態を追跡します。
import { useState } from 'react';
import useSWR from 'swr';
// Debounce hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Search component with dual loading states
export function SearchComponent() {
const [searchInput, setSearchInput] = useState('');
const debouncedSearchInput = useDebounce(searchInput, 300);
// Track if we're still debouncing (user is typing)
const isDebouncing = searchInput !== debouncedSearchInput;
// Fetch with debounced value
const { data = [], isLoading } = useLocationSuggestions(debouncedSearchInput);
// Combined loading state: debouncing OR fetching
const suggestionsLoading = isDebouncing || isLoading;
return (
<SearchInput
value={searchInput}
onChange={setSearchInput}
loading={suggestionsLoading}
suggestions={data}
placeholder="Search locations..."
/>
);
}
これが機能する理由
2 種類のローディング状態:
isDebouncing:ユーザーが入力を続けていることを示しますisLoading:ネットワークリクエストが進行中であることを示します- 2 つの状態を組み合わせることで、スムーズな UX フィードバックを実現します
パフォーマンス:
- API 呼び出しは、300ms の間入力がない場合にのみ実行されます
- 入力中の不要なリクエストを減らします
- SWR が結果をキャッシュするため、すぐに表示できます
ユーザーエクスペリエンス:
- 入力を始めるとすぐにローディングインジケーターが表示されます
- デバウンス間隔中に結果がちらつくのを防ぎます
- 入力中とフェッチ中の両方で、明確なフィードバックを提供します
よくある使用場面
次のような場合に、このパターンを使用します:
- 「ローディング状態を備えたデバウンス検索を行う」
- 「入力中に API 呼び出しが多くなりすぎるのを防ぐ」
- 「デバウンス中にローディングインジケーターを表示する」
- 「スムーズな検索エクスペリエンスを実現する」
- 「デバウンスとデータフェッチを組み合わせる」
パターン 3:メモ化されたコンテキストプロバイダー
パターン
値をメモ化したコンテキストプロバイダーを作成し、コンシューマーの不要な再レンダリングを防ぎます。
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react';
// Context value type
interface LocationContextValue {
location: UserLocation | null;
requestPreciseLocation: () => Promise<UserLocation | null>;
}
interface UserLocation {
latitude: number;
longitude: number;
accuracy: 'coarse' | 'precise';
}
// Create context with undefined default
const UserLocationContext = createContext<LocationContextValue | undefined>(
undefined
);
// Provider component
export const UserLocationProvider = ({
location: initialLocation = null,
children
}: {
location?: UserLocation | null;
children: ReactNode;
}) => {
const [preciseLocation, setPreciseLocation] = useState<UserLocation | null>(null);
// Stable callback reference with useCallback
const requestPreciseLocation = useCallback(async () => {
try {
const coords = await getUserCoordinates();
const location = coords
? {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: 'precise' as const
}
: null;
setPreciseLocation(location);
return location;
} catch (error) {
console.error('Failed to get precise location:', error);
return null;
}
}, []); // No dependencies - function is stable
// Memoized context value prevents unnecessary re-renders
const contextValue = useMemo<LocationContextValue>(() => ({
location: preciseLocation || initialLocation,
requestPreciseLocation,
}), [initialLocation, preciseLocation, requestPreciseLocation]);
return (
<UserLocationContext.Provider value={contextValue}>
{children}
</UserLocationContext.Provider>
);
};
// Type-safe hook for consuming context
export const useUserLocation = (): LocationContextValue => {
const context = useContext(UserLocationContext);
if (context === undefined) {
throw new Error('useUserLocation must be used within UserLocationProvider');
}
return context;
};
// Helper function (can be in separate file)
async function getUserCoordinates(): Promise<GeolocationCoordinates | null> {
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(position) => resolve(position.coords),
() => resolve(null),
{ enableHighAccuracy: true }
);
});
}
これが機能する理由
メモ化による再レンダリングの防止:
- コンテキスト値のオブジェクトには
useMemoを使用します - 依存関係が変わった場合にのみ再作成します
- コンシューマーは値が実際に変わった場合にのみ再レンダリングされます
安定した参照:
useCallbackにより、requestPreciseLocationの参照が安定します- 依存関係がないため、関数は変わりません
- 内部状態が更新されても、関数は再作成されません
型安全性:
- プロバイダーの外部で使用された場合、コンテキストフックはエラーをスローします
- TypeScript により、正しい使用方法が保証されます
- 誤った使い方に対して、明確なエラーメッセージを表示します
ベストプラクティス:
- コンテキスト値は複数のプロパティを持つオブジェクトです
- プロバイダーは props と内部状態の両方を管理します
- カスタムフックがコンテキストの利用を抽象化します
よくある使用場面
次のような場合に、このパターンを使用します:
- 「コンテキストが原因で再レンダリングが多すぎる」
- 「コンテキストプロバイダーのパフォーマンスを最適化する」
- 「コンテキスト内のコールバックを安定させる」
- 「コンテキスト値をメモ化する」
- 「コンテキストによる不要なレンダリングを防ぐ」
パターン 4:UI 状態のステートマシン
パターン
判別可能なユニオンとステートマシンを使用して、予測可能な UI 状態管理を実現します。
import { useState } from 'react';
// Discriminated union for request states
type RequestState = 'idle' | 'pending' | 'success' | 'error';
// State-specific configuration
const stateConfig: Record<RequestState, {
label: string;
disabled: boolean;
variant: 'primary' | 'success' | 'danger';
}> = {
idle: {
label: 'Click to start',
disabled: false,
variant: 'primary',
},
pending: {
label: 'Loading...',
disabled: true,
variant: 'primary',
},
success: {
label: 'Complete!',
disabled: false,
variant: 'success',
},
error: {
label: 'Failed - try again',
disabled: false,
variant: 'danger',
},
};
// Component using state machine
export function AsyncButton({
onClick
}: {
onClick: () => Promise<void>;
}) {
const [state, setState] = useState<RequestState>('idle');
const handleClick = async () => {
// State transition: idle/error → pending
setState('pending');
try {
await onClick();
// State transition: pending → success
setState('success');
// Auto-reset after 2 seconds
setTimeout(() => setState('idle'), 2000);
} catch (error) {
// State transition: pending → error
setState('error');
}
};
const config = stateConfig[state];
return (
<button
onClick={handleClick}
disabled={config.disabled}
className={`btn btn-${config.variant}`}
>
{config.label}
</button>
);
}
応用:データを持つタグ付きユニオン
状態ごとに異なるデータを持つ、より複雑な状態の場合:
// Tagged union with discriminated state
type AsyncState<T, E = Error> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E };
function useAsyncOperation<T>(
operation: () => Promise<T>
): {
state: AsyncState<T>;
execute: () => Promise<void>;
reset: () => void;
} {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
const execute = async () => {
setState({ status: 'loading' });
try {
const data = await operation();
setState({ status: 'success', data });
} catch (error) {
setState({
status: 'error',
error: error instanceof Error ? error : new Error('Unknown error')
});
}
};
const reset = () => {
setState({ status: 'idle' });
};
return { state, execute, reset };
}
// Usage in component
export function DataFetchButton() {
const { state, execute, reset } = useAsyncOperation(async () => {
const response = await fetch('/api/data');
return response.json();
});
return (
<div>
<button onClick={execute} disabled={state.status === 'loading'}>
Fetch Data
</button>
{state.status === 'loading' && <Spinner />}
{state.status === 'success' && (
<div>
<pre>{JSON.stringify(state.data, null, 2)}</pre>
<button onClick={reset}>Reset</button>
</div>
)}
{state.status === 'error' && (
<div className="error">
{state.error.message}
<button onClick={reset}>Try Again</button>
</div>
)}
</div>
);
}
これがうまく機能する理由
予測可能な状態遷移:
- 明示的な状態によって、あり得ない状態を防げます
- 状態遷移のロジックが明確になります
- TypeScript により、すべての状態が処理されることが保証されます
型安全性:
- 判別可能なユニオンによって、網羅的なチェックが可能になります
- 状態ごとのデータが型安全になります
- コンパイラーが、処理されていない状態を検出します
保守性:
- 状態設定を1か所にまとめられます
- 状態を簡単に追加できます
- 状態と UI を明確に分離できます
よくあるトリガー
次のような場合に、このパターンを使用します:
- 「非同期処理の状態を管理する」
- 「予測可能な UI ステートマシン」
- 「読み込み、成功、エラーの状態」
- 「あり得ない状態を防ぐ」
- 「型安全な状態管理」
高度な組み合わせ:パターンを組み合わせる
検索コンポーネントの完成形
デバウンス検索、SWR によるフェッチ、ステートマシンを組み合わせます:
import { useState, useMemo } from 'react';
import useSWR from 'swr';
type SearchState = 'idle' | 'debouncing' | 'fetching' | 'success' | 'error';
export function AdvancedSearch() {
const [searchInput, setSearchInput] = useState('');
const debouncedInput = useDebounce(searchInput, 300);
// Determine current state
const isDebouncing = searchInput !== debouncedInput;
// Conditional SWR fetching
const { data, error, isLoading } = useSWR(
debouncedInput.length >= 2
? `/api/search?q=${encodeURIComponent(debouncedInput)}`
: null
);
// Compute search state
const searchState = useMemo<SearchState>(() => {
if (error) return 'error';
if (isDebouncing) return 'debouncing';
if (isLoading) return 'fetching';
if (data) return 'success';
return 'idle';
}, [isDebouncing, isLoading, data, error]);
// State-specific UI
const showSpinner = searchState === 'debouncing' || searchState === 'fetching';
const showResults = searchState === 'success';
const showError = searchState === 'error';
return (
<div>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search..."
/>
{showSpinner && <Spinner />}
{showResults && (
<SearchResults results={data.results} />
)}
{showError && (
<ErrorMessage>Failed to fetch results</ErrorMessage>
)}
</div>
);
}
避けるべきアンチパターン
❌ 禁止:レンダー時に新しいオブジェクトを作成する
// BAD: New object on every render causes unnecessary re-renders
function MyProvider({ children }) {
const [state, setState] = useState(null);
return (
<MyContext.Provider value={{ state, setState }}>
{children}
</MyContext.Provider>
);
}
// GOOD: Memoize the context value
function MyProvider({ children }) {
const [state, setState] = useState(null);
const value = useMemo(() => ({ state, setState }), [state]);
return (
<MyContext.Provider value={value}>
{children}
</MyContext.Provider>
);
}
❌ 禁止:SWR の条件付きフェッチを忘れる
// BAD: Fetches even with empty input
function useSearch(query: string) {
const { data } = useSWR(`/api/search?q=${query}`);
return data;
}
// GOOD: Only fetch when query is valid
function useSearch(query: string) {
const { data } = useSWR(
query.trim().length >= 2 ? `/api/search?q=${query}` : null
);
return data;
}
❌ 禁止:デバウンスと読み込み状態の違いを無視する
// BAD: Only shows loading during network request
function Search() {
const [input, setInput] = useState('');
const debounced = useDebounce(input, 300);
const { data, isLoading } = useSWR(`/api?q=${debounced}`);
// Spinner disappears while debouncing!
return <>{isLoading && <Spinner />}</>;
}
// GOOD: Show loading during both debounce and fetch
function Search() {
const [input, setInput] = useState('');
const debounced = useDebounce(input, 300);
const { data, isLoading } = useSWR(`/api?q=${debounced}`);
const isDebouncing = input !== debounced;
const loading = isDebouncing || isLoading;
return <>{loading && <Spinner />}</>;
}
❌ 禁止:型安全性のない文字列の状態を使用する
// BAD: Easy to typo, no autocomplete
function Component() {
const [status, setStatus] = useState('idel'); // Typo!
if (status === 'idle') { // Won't match
return <div>Ready</div>;
}
}
// GOOD: Use discriminated union
type Status = 'idle' | 'loading' | 'success' | 'error';
function Component() {
const [status, setStatus] = useState<Status>('idle');
if (status === 'idle') { // Type-safe
return <div>Ready</div>;
}
}
ベストプラクティスのまとめ
- 条件付き SWR キー:条件が満たされない場合にフェッチを防ぐには、
nullを使用します - 変換処理のメモ化:コストの高いデータ変換には
useMemoを使用します - 安定したコールバック:コンテキストや依存関係で使用する関数には
useCallbackを使用します - コンテキスト値のメモ化:コンテキストのコンシューマーで不要な再レンダーが発生しないようにします
- 2つの読み込み状態:デバウンスとネットワークの読み込みを別々に追跡します
- 純粋なヘルパー関数:テストしやすくするため、ロジックをコンポーネントの外に切り出します
- 型安全な状態:ステートマシンには判別可能なユニオンを使用します
- 明示的な状態遷移:状態変更を予測可能で明確にします
関連パターン
XState を使ったより高度なステートマシンパターンについては、react-state-machine スキルを参照してください。React 19 のプラットフォーム機能とレンダリングアーキテクチャ(手動メモ化が必要となるタイミングを変える React Compiler、並行レンダリング、Actions、大規模なコンテキスト最適化)については、react-advanced スキルを参照してください。