Tasuke Hubのロゴ

ITを中心に困っている人を助けるメディア

分かりやすく解決策を提供することで、あなたの困ったをサポート。 全ての人々がスムーズに生活できる世界を目指します。

ReactとJSXを使いこなす!上級者のためのテクニックとベストプラクティス

記事のサムネイル
TH

Tasuke Hub管理人

東証プライム市場上場企業エンジニア

情報系修士卒業後、大手IT企業にてフルスタックエンジニアとして活躍。 Webアプリケーション開発からクラウドインフラ構築まで幅広い技術に精通し、 複数のプロジェクトでリードエンジニアを担当。 技術ブログやオープンソースへの貢献を通じて、日本のIT技術コミュニティに積極的に関わっている。

🎓情報系修士🏢東証プライム上場企業💻フルスタックエンジニア📝技術ブログ執筆者

更新履歴

2025/05/09: 記事の内容を大幅に見直しました。

おすすめの書籍

ReactとJSXを使いこなす!上級者のためのテクニックとベストプラクティス

Reactを使ったウェブ開発は現代のフロントエンド開発において主流となっています。基礎を押さえたら、次のステップとして上級者のテクニックとベストプラクティスを学び、アプリケーションのパフォーマンスと品質を向上させましょう。この記事では、経験を積んだReact開発者がさらにスキルを磨くための実践的なテクニックをご紹介します。

メモ化を極める:React.memo、useMemo、useCallback

Reactアプリケーションのパフォーマンスを向上させる最も効果的な手法の一つがメモ化です。不必要な再レンダリングを防ぎ、計算コストの高い処理を最適化することで、アプリケーションの応答性を大幅に改善できます。

React.memoの活用:

// 通常のコンポーネント
const UserProfile = ({ user, onUpdate }) => {
  console.log('UserProfile rendering');
  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={onUpdate}>更新</button>
    </div>
  );
};

// メモ化されたコンポーネント
const MemoizedUserProfile = React.memo(UserProfile);

React.memoはコンポーネントに渡されるpropsが変更されない限り、コンポーネントの再レンダリングをスキップします。ただし、オブジェクトや関数の参照が毎回変わる場合は効果が薄れるため、次のフックと組み合わせることが重要です。

useMemoの効果的な使い方

const App = () => {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');
  
  // 高コストな計算をメモ化
  const expensiveCalculation = useMemo(() => {
    console.log('Calculating...');
    return count * 1000;
  }, [count]); // countが変わった時だけ再計算
  
  // オブジェクト参照をメモ化
  const userObject = useMemo(() => ({
    id: 1,
    name: 'ユーザー1'
  }), []); // 依存配列が空なので、マウント時にのみ作成
  
  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <p>高コスト計算結果: {expensiveCalculation}</p>
      <button onClick={() => setCount(count + 1)}>カウントアップ</button>
      
      {/* textが変更されてもuserObjectは変わらないので再レンダリングされない */}
      <MemoizedUserProfile user={userObject} onUpdate={() => console.log('更新')} />
    </div>
  );
};

useCallbackでイベントハンドラを最適化

const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [otherState, setOtherState] = useState(0);
  
  // useCallbackを使用してメモ化
  const handleClick = useCallback(() => {
    console.log('Button clicked!');
    setCount(prevCount => prevCount + 1);
  }, []); // 依存配列が空なので、関数の参照は常に同じ
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setOtherState(otherState + 1)}>
        他の状態を更新(再レンダリングをトリガー)
      </button>
      
      {/* otherStateが変わってもhandleClickの参照は変わらないので再レンダリングされない */}
      <MemoizedButton onClick={handleClick} label="カウントアップ" />
    </div>
  );
};

const Button = ({ onClick, label }) => {
  console.log('Button rendering');
  return <button onClick={onClick}>{label}</button>;
};

const MemoizedButton = React.memo(Button);

メモ化の実装にあたっては、「すべてをメモ化する」のではなく、実際にパフォーマンスのボトルネックとなっている部分を特定し、適切に適用することが重要です。開発者ツールのReact Profilerを活用して、再レンダリングの状況を監視しましょう。

「シンプルさを複雑さより優先せよ」というエンジニアリングの格言があります。メモ化はパワフルなツールですが、プロジェクトの規模や状況に応じて適切な箇所に適用することがベストプラクティスです。

コンポーネント設計のベストプラクティス

コンポーネント設計は、保守性の高いReactアプリケーションを構築する上で決定的な役割を果たします。上級者は単にコンポーネントを作成するだけでなく、将来の拡張性と再利用性を念頭に置いた設計を心がけます。

単一責任の原則

各コンポーネントは一つの明確な責任を持つべきです。これによりコードの可読性が向上し、テストも容易になります。

// 悪い例: 複数の責任を持つコンポーネント
const UserDashboard = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);
  
  // データ取得、エラー処理、ユーザー情報表示、統計表示など多くの責任
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return (
    <div>
      <UserProfile user={user} />
      <UserStats userId={userId} />
      <RecentActivity userId={userId} />
      <UserSettings userId={userId} onUpdate={() => {/* 更新ロジック */}} />
    </div>
  );
};

// 良い例: 責任の分離
const UserDashboard = ({ userId }) => {
  return (
    <div>
      <UserProfileContainer userId={userId} />
      <UserStatsContainer userId={userId} />
      <RecentActivityContainer userId={userId} />
      <UserSettingsContainer userId={userId} />
    </div>
  );
};

// コンテナコンポーネント(データ取得の責任)
const UserProfileContainer = ({ userId }) => {
  const { data: user, loading, error } = useUserData(userId);
  
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return <UserProfile user={user} />;
};

コンポジションパターンの活用

コンポジションは、柔軟で再利用可能なコンポーネントを作成するための強力なパターンです。特に、children propsやRender Propsパターンを活用することで、コンポーネントの拡張性が向上します。

// 柔軟なカードコンポーネント
const Card = ({ header, footer, children }) => {
  return (
    <div className="card">
      {header && <div className="card-header">{header}</div>}
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
};

// 様々な形で利用可能
const App = () => {
  return (
    <div>
      {/* シンプルな使用法 */}
      <Card>
        <p>基本的なカードコンテンツ</p>
      </Card>
      
      {/* ヘッダーとフッターを追加 */}
      <Card 
        header={<h2>ユーザー情報</h2>}
        footer={<button>詳細を見る</button>}
      >
        <UserInfo user={user} />
      </Card>
      
      {/* 複雑なコンテンツ */}
      <Card header="統計情報">
        <DataChart data={chartData} />
        <DataTable data={tableData} />
      </Card>
    </div>
  );
};

カスタムフックを用いたロジックの抽出

コンポーネントのロジックとUIを分離することで、コードの可読性と再利用性が大幅に向上します。

// 悪い例: ロジックとUIが密結合
const UserList = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [page, setPage] = useState(1);
  
  useEffect(() => {
    setLoading(true);
    fetch(`/api/users?page=${page}`)
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [page]);
  
  return (
    <div>
      {loading && <Spinner />}
      {error && <div>エラーが発生しました: {error.message}</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <Pagination 
        currentPage={page} 
        onPageChange={setPage} 
      />
    </div>
  );
};

// 良い例: カスタムフックでロジック分離
const usePaginatedUsers = (initialPage = 1) => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [page, setPage] = useState(initialPage);
  
  useEffect(() => {
    setLoading(true);
    fetch(`/api/users?page=${page}`)
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [page]);
  
  return { users, loading, error, page, setPage };
};

// UIのみのコンポーネント
const UserList = () => {
  const { users, loading, error, page, setPage } = usePaginatedUsers();
  
  return (
    <div>
      {loading && <Spinner />}
      {error && <div>エラーが発生しました: {error.message}</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <Pagination 
        currentPage={page} 
        onPageChange={setPage} 
      />
    </div>
  );
};

「建築家のように考え、庭師のように育てる」という言葉があるように、優れたコンポーネント設計は長期的なプロジェクトの成功に不可欠です。最初から完璧にする必要はありませんが、良い原則に基づいて設計することで、後々のリファクタリングやメンテナンスが格段に容易になります。

パフォーマンス最適化テクニック

Reactアプリケーションのパフォーマンス最適化は、上級者にとって必須のスキルです。ユーザー体験を向上させるためには、特にリスト表示、大規模データ処理、アニメーションなどの重い処理を効率的に扱う必要があります。

仮想化リストで大量データを効率的に表示する

大量のデータ(数百〜数千件)を表示する場合、すべての要素をDOMにレンダリングするとパフォーマンスが著しく低下します。react-windowreact-virtualizedなどのライブラリを使用すると、画面に表示されている部分だけをレンダリングするため、メモリ使用量とレンダリング時間を大幅に削減できます。

import { FixedSizeList as List } from 'react-window';

const VirtualizedList = ({ items }) => {
  // 各アイテムをレンダリングする関数
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      {items[index].name}
    </div>
  );

  return (
    <List
      height={400}  // リストの高さ
      width="100%"  // リストの幅
      itemCount={items.length}  // アイテム数
      itemSize={50}  // 各アイテムの高さ
    >
      {Row}
    </List>
  );
};

イベントの最適化

スクロール、リサイズ、入力などの頻繁に発生するイベントをそのまま処理すると、パフォーマンスが低下します。スロットリングとデバウンシングを活用しましょう。

import { useState, useEffect, useCallback } from 'react';
import { debounce, throttle } from 'lodash';

const SearchComponent = () => {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  // 検索API呼び出しをデバウンス(入力が止まってから実行)
  const debouncedSearch = useCallback(
    debounce(async (term) => {
      if (!term) return setResults([]);
      
      setLoading(true);
      try {
        const response = await fetch(`/api/search?q=${term}`);
        const data = await response.json();
        setResults(data);
      } catch (error) {
        console.error('検索エラー:', error);
      } finally {
        setLoading(false);
      }
    }, 500), // 500ms後に実行
    []
  );

  // 入力が変更されるたびにデバウンスされた検索を呼び出す
  useEffect(() => {
    debouncedSearch(searchTerm);
    
    // クリーンアップ関数(アンマウント時や次の効果の前に呼ばれる)
    return () => {
      debouncedSearch.cancel();
    };
  }, [searchTerm, debouncedSearch]);

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="検索..."
      />
      {loading && <div>検索中...</div>}
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
};

Code Splitting とLazy Loading

アプリケーションが大きくなると、初期ロード時間が長くなります。React.lazyとSuspenseを使用して、必要になるまでコンポーネントをロードしないようにしましょう。

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

// 必要になるまでロードしないコンポーネント
const HomePage = lazy(() => import('./pages/HomePage'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const Settings = lazy(() => import('./pages/Settings'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

const App = () => {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/profile" element={<UserProfile />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="/dashboard/*" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </Router>
  );
};

useTransitionによるUIブロッキング防止

React 18で導入されたuseTransitionフックを使用すると、重い状態更新を「非緊急」としてマークでき、UIがブロックされることを防止できます。

import { useState, useTransition } from 'react';

const FilterableList = ({ items }) => {
  const [filterText, setFilterText] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  const handleFilterChange = (e) => {
    // フィルターテキストの更新は緊急(即時反映)
    const text = e.target.value;
    setFilterText(text);
    
    // 大量のデータフィルタリングは非緊急としてマーク
    startTransition(() => {
      const filtered = items.filter(item => 
        item.name.toLowerCase().includes(text.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  };

  return (
    <div>
      <input
        type="text"
        value={filterText}
        onChange={handleFilterChange}
        placeholder="フィルター..."
      />
      
      {isPending ? (
        <div>フィルタリング中...</div>
      ) : (
        <ul>
          {filteredItems.map(item => (
            <li key={item.id}>{item.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
};

「過度な最適化は悪の根源である」(ドナルド・クヌース)という言葉を心に留めておきましょう。最適化は必要な場所に的を絞って行い、コードの可読性とメンテナンス性のバランスを取ることが重要です。パフォーマンスのボトルネックを特定するためには、React Developer ToolsのProfierや、Lighthouseなどのツールを活用してください。

状態管理の高度な手法

大規模なReactアプリケーションでは、状態管理が複雑化しがちです。上級者はこの複雑さに対処するための高度な手法を知っておく必要があります。

Contextの効果的な使い方

React Contextは、グローバルな状態を管理するのに適していますが、使い方によってはパフォーマンスに悪影響を与える可能性があります。

// コンテキストの分割
// 悪い例: すべての状態を一つのコンテキストに入れる
const AppContext = React.createContext();

const AppProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);
  const [settings, setSettings] = useState({});
  
  // すべての状態が変わると、このプロバイダーを使用しているすべてのコンポーネントが再レンダリング
  return (
    <AppContext.Provider 
      value={{ 
        user, setUser, 
        theme, setTheme, 
        notifications, setNotifications, 
        settings, setSettings 
      }}
    >
      {children}
    </AppContext.Provider>
  );
};

// 良い例: 目的別にコンテキストを分割
const UserContext = React.createContext();
const ThemeContext = React.createContext();
const NotificationContext = React.createContext();
const SettingsContext = React.createContext();

const AppProviders = ({ children }) => {
  return (
    <UserProvider>
      <ThemeProvider>
        <NotificationProvider>
          <SettingsProvider>
            {children}
          </SettingsProvider>
        </NotificationProvider>
      </ThemeProvider>
    </UserProvider>
  );
};

// 各コンポーネントは必要なコンテキストだけを購読できる
const Header = () => {
  const { user } = useContext(UserContext);
  const { theme } = useContext(ThemeContext);
  // notificationsとsettingsが変更されても再レンダリングされない
  
  return <header className={theme}>Welcome, {user.name}</header>;
};

ReduxとReact Queryの使い分け

Reduxは長らくReactアプリケーションの状態管理の主力でしたが、すべての状態をReduxで管理することは必ずしも最適ではありません。

// サーバー状態(APIデータ)の管理はReact Queryを使用
import { QueryClient, QueryClientProvider, useQuery, useMutation } from 'react-query';

const queryClient = new QueryClient();

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <UserDashboard />
    </QueryClientProvider>
  );
};

const UserDashboard = () => {
  // サーバーデータの取得とキャッシュはReact Queryで簡単に
  const { data: users, isLoading, error } = useQuery(
    'users', 
    () => fetch('/api/users').then(res => res.json()),
    { staleTime: 5 * 60 * 1000 } // 5分間はキャッシュを使用
  );
  
  // データの更新もシンプル
  const mutation = useMutation(
    newUser => fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(newUser)
    }).then(res => res.json()),
    {
      // 成功したら既存のクエリを再検証
      onSuccess: () => {
        queryClient.invalidateQueries('users');
      }
    }
  );
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <div>
      <h1>ユーザー一覧</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <button
        onClick={() => {
          mutation.mutate({ name: 'New User' });
        }}
      >
        ユーザーを追加
      </button>
    </div>
  );
};

Zustandによるシンプルな状態管理

Reduxが複雑すぎると感じる場合、Zustandはシンプルながら強力な代替手段です。

import create from 'zustand';

// 状態の定義と更新ロジックを一箇所にまとめられる
const useStore = create((set) => ({
  count: 0,
  users: [],
  isLoading: false,
  error: null,
  
  // アクションはシンプルな関数
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 })),
  
  // 非同期アクションも簡単に実装
  fetchUsers: async () => {
    set({ isLoading: true, error: null });
    try {
      const response = await fetch('/api/users');
      const users = await response.json();
      set({ users, isLoading: false });
    } catch (error) {
      set({ error, isLoading: false });
    }
  }
}));

// コンポーネントでの使用も簡単
const Counter = () => {
  // 必要な状態とアクションだけを選択的に取得
  const { count, increment, decrement } = useStore();
  
  return (
    <div>
      <h2>カウント: {count}</h2>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
};

const UserList = () => {
  // 他のコンポーネントとは独立して別の状態を取得
  const { users, isLoading, error, fetchUsers } = useStore();
  
  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

useReducerとimmerの組み合わせ

複雑な状態更新を扱う場合、useReducerとimmerの組み合わせは非常に強力です。

import { useReducer } from 'react';
import produce from 'immer';

// 初期状態
const initialState = {
  users: [],
  selectedUserId: null,
  isEditing: false,
  form: {
    name: '',
    email: '',
    role: 'user'
  },
  errors: {}
};

// immerを使用したReducer
const reducer = (state, action) => 
  produce(state, draft => {
    switch (action.type) {
      case 'SELECT_USER':
        draft.selectedUserId = action.payload;
        draft.isEditing = false;
        break;
        
      case 'START_EDITING':
        const user = draft.users.find(u => u.id === draft.selectedUserId);
        if (user) {
          draft.form = { ...user };
          draft.isEditing = true;
          draft.errors = {};
        }
        break;
        
      case 'UPDATE_FORM':
        draft.form[action.field] = action.value;
        // エラーがあれば自動的にクリア
        if (draft.errors[action.field]) {
          delete draft.errors[action.field];
        }
        break;
        
      case 'VALIDATE_FORM':
        // バリデーションロジック
        if (!draft.form.name) {
          draft.errors.name = '名前は必須です';
        }
        if (!draft.form.email) {
          draft.errors.email = 'メールアドレスは必須です';
        }
        break;
        
      case 'SAVE_USER':
        if (Object.keys(draft.errors).length === 0) {
          const index = draft.users.findIndex(u => u.id === draft.selectedUserId);
          if (index >= 0) {
            draft.users[index] = { ...draft.form, id: draft.selectedUserId };
          }
          draft.isEditing = false;
        }
        break;
        
      case 'CANCEL_EDITING':
        draft.isEditing = false;
        draft.errors = {};
        break;
        
      default:
        break;
    }
  });

const UserManager = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { users, selectedUserId, isEditing, form, errors } = state;
  
  // 処理はディスパッチアクションを通じて行う
  const handleUserSelect = (userId) => {
    dispatch({ type: 'SELECT_USER', payload: userId });
  };
  
  const handleEditClick = () => {
    dispatch({ type: 'START_EDITING' });
  };
  
  const handleFormChange = (field, value) => {
    dispatch({ type: 'UPDATE_FORM', field, value });
  };
  
  const handleSave = () => {
    dispatch({ type: 'VALIDATE_FORM' });
    dispatch({ type: 'SAVE_USER' });
  };
  
  const handleCancel = () => {
    dispatch({ type: 'CANCEL_EDITING' });
  };
  
  // コンポーネントのレンダリング...
};

「シンプルなものをシンプルに保ち、複雑なものを管理しやすくする」ことが重要です。小規模なアプリケーションでは過剰な状態管理を導入せず、プロジェクトの成長に合わせて適切なソリューションを選択しましょう。

テスト駆動開発(TDD)による品質向上

上級者のReact開発においてテスト駆動開発(TDD)は、単なる品質保証の手段ではなく、より良いコンポーネント設計を促進し、リファクタリングに自信を持つための重要な方法論です。

Reactコンポーネントのテスト戦略

Reactコンポーネントには複数のテストアプローチがあります。用途に応じて適切な戦略を選択することが重要です。

// テスト対象のコンポーネント
const Counter = () => {
  const [count, setCount] = useState(0);
  
  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  
  return (
    <div data-testid="counter">
      <p data-testid="count">{count}</p>
      <button data-testid="increment" onClick={increment}>+</button>
      <button data-testid="decrement" onClick={decrement}>-</button>
    </div>
  );
};

// React Testing Libraryを使用したテスト例
import { render, screen, fireEvent } from '@testing-library/react';

describe('Counter', () => {
  // 各テスト前にコンポーネントをレンダリング
  beforeEach(() => {
    render(<Counter />);
  });
  
  test('初期状態のカウントは0', () => {
    expect(screen.getByTestId('count')).toHaveTextContent('0');
  });
  
  test('インクリメントボタンをクリックするとカウントが1増える', () => {
    fireEvent.click(screen.getByTestId('increment'));
    expect(screen.getByTestId('count')).toHaveTextContent('1');
  });
  
  test('デクリメントボタンをクリックするとカウントが1減る', () => {
    fireEvent.click(screen.getByTestId('increment')); // まず1に増やす
    fireEvent.click(screen.getByTestId('decrement')); // その後1減らす
    expect(screen.getByTestId('count')).toHaveTextContent('0');
  });
});

カスタムフックの単体テスト

カスタムフックの単体テストは、コンポーネントからロジックを分離し、より集中的にテストするのに役立ちます。

// テスト対象のカスタムフック
import { useState, useCallback } from 'react';

export const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);
  
  const increment = useCallback(() => setCount(prev => prev + 1), []);
  const decrement = useCallback(() => setCount(prev => prev - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  
  return { count, increment, decrement, reset };
};

// @testing-library/react-hooks を使用したテスト例
import { renderHook, act } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  test('初期値で正しく初期化される', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });
  
  test('incrementでカウントが増加する', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  test('decrementでカウントが減少する', () => {
    const { result } = renderHook(() => useCounter(10));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(9);
  });
  
  test('resetで初期値に戻る', () => {
    const { result } = renderHook(() => useCounter(3));
    
    act(() => {
      result.current.increment();
      result.current.increment();
    });
    
    expect(result.current.count).toBe(5);
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(3);
  });
});

統合テストによるコンポーネント間の相互作用の検証

複数のコンポーネントが連携する場合、統合テストは重要な役割を果たします。

// テスト対象のコンポーネント
const UserForm = ({ onSubmit }) => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ name, email });
  };
  
  return (
    <form data-testid="user-form" onSubmit={handleSubmit}>
      <input
        data-testid="name-input"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="名前"
      />
      <input
        data-testid="email-input"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="メールアドレス"
      />
      <button data-testid="submit-button" type="submit">送信</button>
    </form>
  );
};

const UserList = ({ users }) => {
  return (
    <ul data-testid="user-list">
      {users.map((user, index) => (
        <li key={index} data-testid={`user-item-${index}`}>
          {user.name} ({user.email})
        </li>
      ))}
    </ul>
  );
};

const UserManager = () => {
  const [users, setUsers] = useState([]);
  
  const handleAddUser = (user) => {
    setUsers([...users, user]);
  };
  
  return (
    <div>
      <UserForm onSubmit={handleAddUser} />
      <UserList users={users} />
    </div>
  );
};

// 統合テスト
import { render, screen, fireEvent } from '@testing-library/react';

describe('UserManager', () => {
  test('新しいユーザーを追加できる', () => {
    render(<UserManager />);
    
    // フォームに入力
    fireEvent.change(screen.getByTestId('name-input'), {
      target: { value: 'テスト太郎' }
    });
    fireEvent.change(screen.getByTestId('email-input'), {
      target: { value: '[email protected]' }
    });
    
    // フォームを送信
    fireEvent.submit(screen.getByTestId('user-form'));
    
    // ユーザーリストに新しいユーザーが表示されることを確認
    expect(screen.getByTestId('user-item-0')).toHaveTextContent('テスト太郎 ([email protected])');
  });
});

モックを活用したテスト

外部依存関係(APIなど)に依存するコンポーネントをテストする場合、モックが効果的です。

// APIを呼び出すコンポーネント
const UserData = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await fetch('/api/user/1');
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.toString());
      } finally {
        setLoading(false);
      }
    };
    
    fetchUser();
  }, []);
  
  if (loading) return <div data-testid="loading">Loading...</div>;
  if (error) return <div data-testid="error">{error}</div>;
  
  return (
    <div data-testid="user-data">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
};

// モックを使用したテスト
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';

// fetchをモック化
global.fetch = jest.fn();

describe('UserData', () => {
  test('データ取得に成功した場合、ユーザー情報が表示される', async () => {
    // モックレスポンスを設定
    fetch.mockResolvedValueOnce({
      json: async () => ({ name: 'テスト太郎', email: '[email protected]' })
    });
    
    render(<UserData />);
    
    // まずローディング状態を確認
    expect(screen.getByTestId('loading')).toBeInTheDocument();
    
    // ローディングが消えるのを待つ
    await waitForElementToBeRemoved(() => screen.getByTestId('loading'));
    
    // ユーザーデータが表示されていることを確認
    expect(screen.getByTestId('user-data')).toHaveTextContent('テスト太郎');
    expect(screen.getByTestId('user-data')).toHaveTextContent('[email protected]');
  });
  
  test('データ取得に失敗した場合、エラーが表示される', async () => {
    // エラーを返すモックを設定
    fetch.mockRejectedValueOnce(new Error('APIエラー'));
    
    render(<UserData />);
    
    // ローディングが消えるのを待つ
    await waitForElementToBeRemoved(() => screen.getByTestId('loading'));
    
    // エラーが表示されていることを確認
    expect(screen.getByTestId('error')).toHaveTextContent('Error: APIエラー');
  });
});

「テストを書かないコードは、最初から壊れているものと同じ」という格言がありますが、特にReactのような宣言的なUIライブラリでは、テストによってコンポーネントの堅牢性を確保することがとても重要です。TDDアプローチを取り入れ、テストファーストの文化を育てることで、長期的には開発速度の向上とバグの減少に大きく貢献します。

モダンReactフックの応用

React Hooksは登場以来、Reactの開発方法を革新しました。上級者は標準フックを超えて、より高度なパターンとカスタムフックを活用することでコードの再利用性と可読性を高めることができます。

useReducerとコンテキストの組み合わせによる状態管理

Redux風のパターンをフックだけで実現できます。

// 共有状態とロジックを持つコンテキスト
import { createContext, useReducer, useContext } from 'react';

// アクションタイプを定数として定義
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const DELETE_TODO = 'DELETE_TODO';

// 初期状態
const initialState = {
  todos: []
};

// リデューサー関数
const reducer = (state, action) => {
  switch (action.type) {
    case ADD_TODO:
      return {
        ...state,
        todos: [...state.todos, {
          id: Date.now(),
          text: action.payload,
          completed: false
        }]
      };
      
    case TOGGLE_TODO:
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
      
    case DELETE_TODO:
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload)
      };
      
    default:
      return state;
  }
};

// コンテキストを作成
const TodoContext = createContext();

// プロバイダーコンポーネント
export const TodoProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  // アクションクリエーター
  const addTodo = (text) => {
    dispatch({ type: ADD_TODO, payload: text });
  };
  
  const toggleTodo = (id) => {
    dispatch({ type: TOGGLE_TODO, payload: id });
  };
  
  const deleteTodo = (id) => {
    dispatch({ type: DELETE_TODO, payload: id });
  };
  
  return (
    <TodoContext.Provider value={{
      todos: state.todos,
      addTodo,
      toggleTodo,
      deleteTodo
    }}>
      {children}
    </TodoContext.Provider>
  );
};

// カスタムフック
export const useTodos = () => {
  const context = useContext(TodoContext);
  if (!context) {
    throw new Error('useTodos must be used within a TodoProvider');
  }
  return context;
};

// 使用例
const TodoApp = () => {
  return (
    <TodoProvider>
      <TodoList />
      <AddTodoForm />
    </TodoProvider>
  );
};

const TodoList = () => {
  const { todos, toggleTodo, deleteTodo } = useTodos();
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
          <span onClick={() => toggleTodo(todo.id)}>{todo.text}</span>
          <button onClick={() => deleteTodo(todo.id)}>削除</button>
        </li>
      ))}
    </ul>
  );
};

const AddTodoForm = () => {
  const { addTodo } = useTodos();
  const [text, setText] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      addTodo(text);
      setText('');
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={text} 
        onChange={(e) => setText(e.target.value)} 
        placeholder="TODOを追加" 
      />
      <button type="submit">追加</button>
    </form>
  );
};

useImperativeHandleによる子コンポーネントの機能公開

親コンポーネントから子コンポーネントの特定のメソッドを呼び出したいときに使用します。

import { useRef, useImperativeHandle, forwardRef } from 'react';

// refを受け取るコンポーネント
const VideoPlayer = forwardRef((props, ref) => {
  const videoRef = useRef(null);
  
  // 親コンポーネントに公開したいメソッドを定義
  useImperativeHandle(ref, () => ({
    play() {
      videoRef.current.play();
    },
    pause() {
      videoRef.current.pause();
    },
    // 動画の現在時間を0に戻す
    reset() {
      videoRef.current.currentTime = 0;
    },
    // 指定した時間に移動
    seekTo(time) {
      videoRef.current.currentTime = time;
    }
  }));
  
  return (
    <video
      ref={videoRef}
      src={props.src}
      style={{ width: '100%' }}
      controls={false}
    />
  );
});

// 親コンポーネント
const VideoController = () => {
  const playerRef = useRef(null);
  
  return (
    <div>
      <VideoPlayer
        ref={playerRef}
        src="https://example.com/video.mp4"
      />
      <div>
        <button onClick={() => playerRef.current.play()}>再生</button>
        <button onClick={() => playerRef.current.pause()}>一時停止</button>
        <button onClick={() => playerRef.current.reset()}>最初から</button>
        <button onClick={() => playerRef.current.seekTo(30)}>30秒へジャンプ</button>
      </div>
    </div>
  );
};

useLayoutEffectによる同期的なDOM更新

useEffectは非同期に実行されますが、DOMの測定と更新を同期的に行いたい場合はuseLayoutEffectが役立ちます。

import { useState, useLayoutEffect, useRef } from 'react';

// ウィンドウサイズに応じて要素のサイズを調整するコンポーネント
const ResponsiveSquare = () => {
  const [size, setSize] = useState(0);
  const containerRef = useRef(null);
  
  // useLayoutEffectはDOMの更新前に同期的に実行される
  useLayoutEffect(() => {
    const updateSize = () => {
      // コンテナの幅に基づいて正方形のサイズを計算
      const width = containerRef.current.clientWidth;
      // 親要素の幅と画面の高さの70%のうち、小さい方を採用
      const maxSize = Math.min(width, window.innerHeight * 0.7);
      setSize(maxSize);
    };
    
    // 初期サイズを設定
    updateSize();
    
    // リサイズイベントでサイズを更新
    window.addEventListener('resize', updateSize);
    return () => window.removeEventListener('resize', updateSize);
  }, []);
  
  return (
    <div ref={containerRef} style={{ width: '100%' }}>
      <div
        style={{
          width: size,
          height: size,
          backgroundColor: 'blue',
          transition: 'all 0.3s ease'
        }}
      />
    </div>
  );
};

カスタムフックによるロジックの共有

複数のコンポーネントで共通のロジックを再利用するためのカスタムフックを作成しましょう。

// カスタムフック: フォーム入力の処理
const useForm = (initialValues = {}) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  // フォームをリセット
  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);
  
  // 入力値が変更されたとき
  const handleChange = useCallback((e) => {
    const { name, value } = e.target;
    setValues(prev => ({
      ...prev,
      [name]: value
    }));
  }, []);
  
  // フィールドがフォーカスを失ったとき
  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched(prev => ({
      ...prev,
      [name]: true
    }));
  }, []);
  
  // バリデーション関数をセット
  const setValidation = useCallback((validateFn) => {
    // 値が変更されるたびにバリデーションを実行
    const newErrors = validateFn(values);
    setErrors(newErrors);
    
    // エラーがなければtrue、あればfalseを返す
    return Object.keys(newErrors).length === 0;
  }, [values]);
  
  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    resetForm,
    setValidation
  };
};

// 使用例
const SignupForm = () => {
  const {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    resetForm,
    setValidation
  } = useForm({
    name: '',
    email: '',
    password: ''
  });
  
  const validateForm = (values) => {
    const errors = {};
    
    if (!values.name) {
      errors.name = '名前は必須です';
    }
    
    if (!values.email) {
      errors.email = 'メールアドレスは必須です';
    } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) {
      errors.email = '有効なメールアドレスを入力してください';
    }
    
    if (!values.password) {
      errors.password = 'パスワードは必須です';
    } else if (values.password.length < 8) {
      errors.password = 'パスワードは8文字以上である必要があります';
    }
    
    return errors;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // フォームを検証
    const isValid = setValidation(validateForm);
    
    if (isValid) {
      // API呼び出しなどの処理
      console.log('フォーム送信:', values);
      resetForm();
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>名前:</label>
        <input
          type="text"
          name="name"
          value={values.name}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.name && errors.name && <div className="error">{errors.name}</div>}
      </div>
      
      <div>
        <label>メールアドレス:</label>
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.email && errors.email && <div className="error">{errors.email}</div>}
      </div>
      
      <div>
        <label>パスワード:</label>
        <input
          type="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.password && errors.password && <div className="error">{errors.password}</div>}
      </div>
      
      <button type="submit">登録</button>
    </form>
  );
};

「シンプルなものは明快に、複雑なものは管理しやすく」という考え方は、Reactフックの応用においても重要です。目的に応じて適切なフックを組み合わせ、再利用可能なロジックを抽出することで、より保守性の高いReactアプリケーションを構築できます。

開発を進める中で「自分はコードを書くのではなく、コードを育てているのだ」という意識を持つと、より良い設計に近づけるでしょう。

あわせて読みたい

おすすめの書籍

おすすめ記事

おすすめコンテンツ