mcpField
R
react-hooks-composition

react-hooks-composition

github.com/bobmatnyc開発78190 回インストール15 週間前に更新
リポジトリ

react-hooks-composition について

react-hooks-composition は、React と TypeScript 向けのカスタムフック構成パターンを解説するガイドです。SWR による取得、検索のデバウンス、コンテキストの最適化、ステートマシンを扱います。

主な機能

  1. 01SWR の条件付きデータ取得
  2. 02二重の読み込み状態を持つ検索デバウンス
  3. 03メモ化されたコンテキストプロバイダー
  4. 04型安全なステートマシン
  5. 05テストしやすい純粋なヘルパー関数

活用シーン

  1. 01ユーザー入力に応じたデータ取得
  2. 02検索入力中の API 呼び出し削減
  3. 03コンテキスト利用側の不要な再レンダリング防止
  4. 04非同期処理の読み込み・エラー状態の管理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill react-hooks-composition

インストール後、Claude Code で/react-hooks-compositionを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

metadata.json
references/
performance-optimization.md
swr-patterns.md
testing-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>;
  }
}

ベストプラクティスのまとめ

  1. 条件付き SWR キー:条件が満たされない場合にフェッチを防ぐには、null を使用します
  2. 変換処理のメモ化:コストの高いデータ変換には useMemo を使用します
  3. 安定したコールバック:コンテキストや依存関係で使用する関数には useCallback を使用します
  4. コンテキスト値のメモ化:コンテキストのコンシューマーで不要な再レンダーが発生しないようにします
  5. 2つの読み込み状態:デバウンスとネットワークの読み込みを別々に追跡します
  6. 純粋なヘルパー関数:テストしやすくするため、ロジックをコンポーネントの外に切り出します
  7. 型安全な状態:ステートマシンには判別可能なユニオンを使用します
  8. 明示的な状態遷移:状態変更を予測可能で明確にします

関連パターン

XState を使ったより高度なステートマシンパターンについては、react-state-machine スキルを参照してください。React 19 のプラットフォーム機能とレンダリングアーキテクチャ(手動メモ化が必要となるタイミングを変える React Compiler、並行レンダリング、Actions、大規模なコンテキスト最適化)については、react-advanced スキルを参照してください。

参考資料

コメント