React初心者からの脱却|中級者になるための5つのステップ

React初心者から中級者へステップアップするための具体的な5つのステップを解説。実践的なスキル習得方法とロードマップを紹介します。

Learning Next 運営
71 分で読めます

みなさん、React学習を続けていますか?

基本的なアプリは作れるようになったけれど、「これで初心者脱却したと言えるのかな?」と感じることはありませんか? 「中級者になるには何をすればいいの?」と迷っている方も多いと思います。

実は、React初心者から中級者への移行は、単に新しい機能を覚えるだけでは達成できません。 実践的なスキルと深い理解が必要なんです。

この記事では、React初心者から確実に中級者へステップアップするための5つの具体的なステップを解説します。 各ステップで身につけるべきスキルと実践方法を明確にしているので、着実にレベルアップしていけますよ!

初心者と中級者の違いを理解しよう

まず、React初心者と中級者の違いを明確にしましょう。 この違いを理解することで、目指すべきレベルが見えてきます。

初心者レベルでできること

初心者レベルでは、こんなコードが書けるようになります。

function BeginnerLevel() {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([]);
  
  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
  };
  
  const toggleTodo = (id) => {
    setTodos(todos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  return (
    <div>
      <h1>カウント: {count}</h1>
      <button onClick={() => setCount(count + 1)}>+1</button>
      
      <h2>Todoリスト</h2>
      {todos.map(todo => (
        <div key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.text} - {todo.completed ? '完了' : '未完了'}
        </div>
      ))}
      
      <button onClick={() => addTodo('新しいタスク')}>
        タスク追加
      </button>
    </div>
  );
}

このコードを見ると、基本的な機能は実装できているのがわかります。 でも、初心者レベルにはこんな限界があります。

  • シンプルなコンポーネントのみ作成可能
  • グローバル状態管理ができない
  • パフォーマンス最適化の知識なし
  • エラーハンドリングが不十分
  • テストの書き方が分からない
  • 実際のAPI連携経験が少ない

つまり、実用的なアプリケーション開発には限界があるんです。

中級者レベルでできること

一方、中級者レベルではこんなコードが書けるようになります。

import { useReducer, useContext, useCallback, useMemo, memo } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

// 複雑な状態管理
const todoReducer = (state, action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, action.payload],
        lastUpdated: new Date().toISOString()
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    case 'SET_FILTER':
      return { ...state, filter: action.payload };
    default:
      return state;
  }
};

// Context による状態共有
const TodoContext = createContext();

export function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(todoReducer, {
    todos: [],
    filter: 'all',
    lastUpdated: null
  });
  
  const value = useMemo(() => ({ state, dispatch }), [state]);
  
  return (
    <TodoContext.Provider value={value}>
      {children}
    </TodoContext.Provider>
  );
}

// パフォーマンス最適化されたコンポーネント
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
  const handleToggle = useCallback(() => {
    onToggle(todo.id);
  }, [todo.id, onToggle]);
  
  const handleDelete = useCallback(() => {
    onDelete(todo.id);
  }, [todo.id, onDelete]);
  
  return (
    <div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <span onClick={handleToggle}>{todo.text}</span>
      <button onClick={handleDelete}>削除</button>
    </div>
  );
});

// エラーハンドリング
function TodoApp() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, errorInfo) => {
        console.error('Todo app error:', error, errorInfo);
      }}
    >
      <TodoProvider>
        <TodoList />
        <TodoForm />
      </TodoProvider>
    </ErrorBoundary>
  );
}

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <h2>エラーが発生しました</h2>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>再試行</button>
    </div>
  );
}

このコードは、かなり複雑ですね。 でも、中級者レベルになると、これらの技術が当たり前に使えるようになります。

中級者レベルでは、実用的なアプリケーション開発に必要なスキルが身についています。 具体的には以下のようなことができるようになります。

  • 複雑な状態管理(useReducer、Context)
  • パフォーマンス最適化(memo、useCallback、useMemo)
  • 適切なエラーハンドリング
  • カスタムフックの作成
  • テストの実装
  • 実践的なAPI連携
  • アクセシビリティ対応
  • TypeScript統合

こうして比較すると、違いがはっきりしますね。

ステップ1: 高度な状態管理をマスターしよう

最初のステップは、useReducerとContext APIを使った高度な状態管理の習得です。

シンプルなuseStateだけでは、複雑なアプリケーションは作れません。 実際の開発では、もっと高度な状態管理が必要なんです。

useReducerの実践的活用

まずは、useReducerの実践的な使い方を見てみましょう。 ショッピングカートの例を使って説明します。

import { useReducer, createContext, useContext } from 'react';

// アクションタイプの定義
const CART_ACTIONS = {
  ADD_ITEM: 'ADD_ITEM',
  REMOVE_ITEM: 'REMOVE_ITEM',
  UPDATE_QUANTITY: 'UPDATE_QUANTITY',
  CLEAR_CART: 'CLEAR_CART',
  APPLY_COUPON: 'APPLY_COUPON',
  SET_SHIPPING: 'SET_SHIPPING'
};

// reducer関数
function cartReducer(state, action) {
  switch (action.type) {
    case CART_ACTIONS.ADD_ITEM: {
      const existingItem = state.items.find(item => item.id === action.payload.id);
      
      if (existingItem) {
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          )
        };
      }
      
      return {
        ...state,
        items: [...state.items, { ...action.payload, quantity: 1 }]
      };
    }
    
    case CART_ACTIONS.REMOVE_ITEM:
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };
    
    case CART_ACTIONS.UPDATE_QUANTITY:
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: Math.max(0, action.payload.quantity) }
            : item
        ).filter(item => item.quantity > 0)
      };
    
    case CART_ACTIONS.APPLY_COUPON:
      return {
        ...state,
        coupon: action.payload,
        discount: calculateDiscount(state.items, action.payload)
      };
    
    case CART_ACTIONS.SET_SHIPPING:
      return {
        ...state,
        shipping: action.payload
      };
    
    case CART_ACTIONS.CLEAR_CART:
      return {
        ...state,
        items: [],
        coupon: null,
        discount: 0
      };
    
    default:
      return state;
  }
}

このコードを見ると、useReducerの威力がわかります。 複雑な状態変更を、整理された形で管理できるんです。

計算ロジックも分離して管理できます。

// 計算関数
function calculateDiscount(items, coupon) {
  if (!coupon) return 0;
  
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return subtotal * (coupon.discountRate / 100);
}

Context APIによるグローバル状態管理

次に、Context APIを使ってグローバル状態管理を実装してみましょう。

// Context の作成
const CartContext = createContext();

// Provider コンポーネント
export function CartProvider({ children }) {
  const initialState = {
    items: [],
    coupon: null,
    discount: 0,
    shipping: 0
  };
  
  const [state, dispatch] = useReducer(cartReducer, initialState);
  
  // 派生状態の計算
  const derivedState = useMemo(() => {
    const subtotal = state.items.reduce(
      (sum, item) => sum + item.price * item.quantity, 
      0
    );
    const total = subtotal - state.discount + state.shipping;
    const itemCount = state.items.reduce((sum, item) => sum + item.quantity, 0);
    
    return {
      subtotal,
      total,
      itemCount
    };
  }, [state.items, state.discount, state.shipping]);
  
  // アクション関数
  const actions = useMemo(() => ({
    addItem: (item) => dispatch({ type: CART_ACTIONS.ADD_ITEM, payload: item }),
    removeItem: (id) => dispatch({ type: CART_ACTIONS.REMOVE_ITEM, payload: id }),
    updateQuantity: (id, quantity) => 
      dispatch({ type: CART_ACTIONS.UPDATE_QUANTITY, payload: { id, quantity } }),
    applyCoupon: (coupon) => dispatch({ type: CART_ACTIONS.APPLY_COUPON, payload: coupon }),
    setShipping: (amount) => dispatch({ type: CART_ACTIONS.SET_SHIPPING, payload: amount }),
    clearCart: () => dispatch({ type: CART_ACTIONS.CLEAR_CART })
  }), []);
  
  const value = {
    ...state,
    ...derivedState,
    ...actions
  };
  
  return (
    <CartContext.Provider value={value}>
      {children}
    </CartContext.Provider>
  );
}

// カスタムフック
export function useCart() {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within CartProvider');
  }
  return context;
}

このように、ContextとuseReducerを組み合わせると、非常に強力な状態管理ができます。 複雑なアプリケーションでも、状態を整理して管理できるんです。

実際に使ってみると、こんな感じになります。

// 使用例
function ShoppingCart() {
  const { items, total, addItem, removeItem, updateQuantity } = useCart();
  
  return (
    <div className="shopping-cart">
      <h2>ショッピングカート</h2>
      {items.length === 0 ? (
        <p>カートは空です</p>
      ) : (
        <>
          {items.map(item => (
            <div key={item.id} className="cart-item">
              <span>{item.name}</span>
              <span>¥{item.price}</span>
              <input
                type="number"
                value={item.quantity}
                onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
                min="1"
              />
              <button onClick={() => removeItem(item.id)}>削除</button>
            </div>
          ))}
          <div className="cart-total">
            <strong>合計: ¥{total.toLocaleString()}</strong>
          </div>
        </>
      )}
    </div>
  );
}

すごくシンプルに使えますね! これが、高度な状態管理の威力です。

ステップ2: パフォーマンス最適化技術を身につけよう

2番目のステップは、React.memo、useCallback、useMemoを使ったパフォーマンス最適化です。

アプリケーションが大きくなると、パフォーマンスの問題が発生します。 でも大丈夫です!適切な最適化技術を使えば、サクサク動くアプリが作れます。

React.memoによるコンポーネント最適化

まず、React.memoを使った最適化を見てみましょう。

import React, { memo, useCallback, useMemo, useState } from 'react';

// 最適化されたリストアイテム
const ListItem = memo(function ListItem({ 
  item, 
  onEdit, 
  onDelete, 
  onToggleComplete 
}) {
  console.log(`ListItem ${item.id} がレンダリングされました`);
  
  const handleEdit = useCallback(() => {
    onEdit(item.id);
  }, [item.id, onEdit]);
  
  const handleDelete = useCallback(() => {
    onDelete(item.id);
  }, [item.id, onDelete]);
  
  const handleToggle = useCallback(() => {
    onToggleComplete(item.id);
  }, [item.id, onToggleComplete]);
  
  // アイテムの状態に基づくスタイル計算
  const itemStyle = useMemo(() => ({
    opacity: item.completed ? 0.6 : 1,
    textDecoration: item.completed ? 'line-through' : 'none',
    backgroundColor: item.priority === 'high' ? '#ffebee' : 'white'
  }), [item.completed, item.priority]);
  
  return (
    <div className="list-item" style={itemStyle}>
      <div className="item-content">
        <h3>{item.title}</h3>
        <p>{item.description}</p>
        <span className={`priority ${item.priority}`}>
          {item.priority}
        </span>
      </div>
      <div className="item-actions">
        <button onClick={handleToggle}>
          {item.completed ? '未完了にする' : '完了にする'}
        </button>
        <button onClick={handleEdit}>編集</button>
        <button onClick={handleDelete}>削除</button>
      </div>
    </div>
  );
});

このコードでは、memoでコンポーネントをラップしています。 これにより、propsが変更されない限り、コンポーネントは再レンダリングされません。

useCallbackuseMemoも使って、無駄な再計算を防いでいます

最適化されたリストコンポーネント

次に、リスト全体の最適化を見てみましょう。

// 最適化されたリストコンポーネント
function OptimizedList({ items, filter, sortBy }) {
  const [editingId, setEditingId] = useState(null);
  
  // 重い計算をメモ化
  const filteredAndSortedItems = useMemo(() => {
    console.log('リストのフィルタリング・ソートを実行');
    
    let filtered = items;
    
    // フィルタリング
    switch (filter) {
      case 'completed':
        filtered = items.filter(item => item.completed);
        break;
      case 'active':
        filtered = items.filter(item => !item.completed);
        break;
      case 'high-priority':
        filtered = items.filter(item => item.priority === 'high');
        break;
      default:
        filtered = items;
    }
    
    // ソート
    return filtered.sort((a, b) => {
      switch (sortBy) {
        case 'priority':
          const priorityOrder = { high: 3, medium: 2, low: 1 };
          return priorityOrder[b.priority] - priorityOrder[a.priority];
        case 'date':
          return new Date(b.createdAt) - new Date(a.createdAt);
        case 'name':
          return a.title.localeCompare(b.title);
        default:
          return 0;
      }
    });
  }, [items, filter, sortBy]);
  
  // イベントハンドラーをメモ化
  const handleEdit = useCallback((id) => {
    setEditingId(id);
  }, []);
  
  const handleDelete = useCallback((id) => {
    // 削除ロジック
    console.log('削除:', id);
  }, []);
  
  const handleToggleComplete = useCallback((id) => {
    // 完了状態切り替えロジック
    console.log('完了切り替え:', id);
  }, []);
  
  // 統計情報をメモ化
  const stats = useMemo(() => {
    const total = items.length;
    const completed = items.filter(item => item.completed).length;
    const highPriority = items.filter(item => item.priority === 'high').length;
    
    return { total, completed, active: total - completed, highPriority };
  }, [items]);
  
  return (
    <div className="optimized-list">
      <div className="list-stats">
        <span>全体: {stats.total}</span>
        <span>完了: {stats.completed}</span>
        <span>未完了: {stats.active}</span>
        <span>高優先度: {stats.highPriority}</span>
      </div>
      
      <div className="list-items">
        {filteredAndSortedItems.map(item => (
          <ListItem
            key={item.id}
            item={item}
            onEdit={handleEdit}
            onDelete={handleDelete}
            onToggleComplete={handleToggleComplete}
          />
        ))}
      </div>
      
      {filteredAndSortedItems.length === 0 && (
        <div className="empty-state">
          <p>表示するアイテムがありません</p>
        </div>
      )}
    </div>
  );
}

このコードでは、重い計算を全てメモ化しています。 フィルタリングとソートは、依存する値が変更されたときだけ実行されます。

大量データの仮想化

大量のデータを扱う場合は、仮想化という技術も使えます。

// react-window を使用した仮想化
import { FixedSizeList as List } from 'react-window';

const VirtualizedList = memo(function VirtualizedList({ items, onItemClick }) {
  const Row = useCallback(({ index, style }) => {
    const item = items[index];
    
    return (
      <div style={style}>
        <div 
          className="virtual-list-item"
          onClick={() => onItemClick(item)}
        >
          <h4>{item.title}</h4>
          <p>{item.description}</p>
          <span>{item.category}</span>
        </div>
      </div>
    );
  }, [items, onItemClick]);
  
  return (
    <List
      height={600}
      itemCount={items.length}
      itemSize={100}
      overscanCount={5}
    >
      {Row}
    </List>
  );
});

仮想化を使えば、10,000件のデータでもスムーズに表示できます。 大量データを扱うアプリには必須の技術ですね。

ステップ3: カスタムフックを作成しよう

3番目のステップは、ロジックを再利用可能にするカスタムフックの作成です。

カスタムフックは、Reactの隠れた強力な機能です。 一度作れば、どのコンポーネントでも使い回せるんです。

実用的なカスタムフック集

実際の開発でよく使う、実用的なカスタムフックを紹介します。

1. APIデータ取得用フック

import { useState, useEffect, useCallback, useRef } from 'react';

function useApi(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);
  
  const fetchData = useCallback(async (customUrl) => {
    const requestUrl = customUrl || url;
    if (!requestUrl) return;
    
    try {
      setLoading(true);
      setError(null);
      
      // 前のリクエストをキャンセル
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
      
      abortControllerRef.current = new AbortController();
      
      const response = await fetch(requestUrl, {
        ...options,
        signal: abortControllerRef.current.signal
      });
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const result = await response.json();
      setData(result);
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message);
      }
    } finally {
      setLoading(false);
    }
  }, [url, options]);
  
  useEffect(() => {
    fetchData();
    
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [fetchData]);
  
  const refetch = useCallback(() => {
    fetchData();
  }, [fetchData]);
  
  return { data, loading, error, refetch, fetchData };
}

このフックを使えば、どのコンポーネントでも簡単にAPI通信ができます。 リクエストのキャンセルや再取得も自動で処理してくれます。

2. ローカルストレージ同期フック

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });
  
  const setValue = useCallback((value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);
  
  const removeValue = useCallback(() => {
    try {
      window.localStorage.removeItem(key);
      setStoredValue(initialValue);
    } catch (error) {
      console.error(`Error removing localStorage key "${key}":`, error);
    }
  }, [key, initialValue]);
  
  return [storedValue, setValue, removeValue];
}

このフックを使えば、ローカルストレージとの同期が簡単になります。 設定値の保存なんかに便利ですね。

3. フォーム管理フック

function useForm(initialValues, validationSchema) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const validateField = useCallback((name, value) => {
    if (!validationSchema[name]) return null;
    
    const fieldValidation = validationSchema[name];
    for (const rule of fieldValidation) {
      const error = rule(value, values);
      if (error) return error;
    }
    return null;
  }, [validationSchema, values]);
  
  const setValue = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    
    if (touched[name]) {
      const error = validateField(name, value);
      setErrors(prev => ({ ...prev, [name]: error }));
    }
  }, [touched, validateField]);
  
  const setFieldTouched = useCallback((name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
    
    const error = validateField(name, values[name]);
    setErrors(prev => ({ ...prev, [name]: error }));
  }, [validateField, values]);
  
  const validateAll = useCallback(() => {
    const newErrors = {};
    let isValid = true;
    
    Object.keys(validationSchema).forEach(name => {
      const error = validateField(name, values[name]);
      if (error) {
        newErrors[name] = error;
        isValid = false;
      }
    });
    
    setErrors(newErrors);
    setTouched(Object.keys(validationSchema).reduce(
      (acc, key) => ({ ...acc, [key]: true }), {}
    ));
    
    return isValid;
  }, [validationSchema, validateField, values]);
  
  const handleSubmit = useCallback((onSubmit) => {
    return async (event) => {
      if (event) {
        event.preventDefault();
      }
      
      setIsSubmitting(true);
      
      try {
        const isValid = validateAll();
        if (isValid) {
          await onSubmit(values);
        }
      } catch (error) {
        console.error('Form submission error:', error);
      } finally {
        setIsSubmitting(false);
      }
    };
  }, [validateAll, values]);
  
  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);
  
  return {
    values,
    errors,
    touched,
    isSubmitting,
    setValue,
    setFieldTouched,
    validateAll,
    handleSubmit,
    reset
  };
}

このフックを使えば、複雑なフォーム処理も簡単に実装できます。 バリデーションも自動で行ってくれます。

カスタムフックの使用例

実際に使ってみると、こんな感じになります。

function UserManagement() {
  const { data: users, loading, error, refetch } = useApi('/api/users');
  const [preferences, setPreferences] = useLocalStorage('userPreferences', {
    theme: 'light',
    itemsPerPage: 10
  });
  
  const { values, errors, setValue, setFieldTouched, handleSubmit } = useForm(
    { name: '', email: '', role: 'user' },
    {
      name: [
        (value) => !value ? '名前は必須です' : null,
        (value) => value.length < 2 ? '名前は2文字以上で入力してください' : null
      ],
      email: [
        (value) => !value ? 'メールアドレスは必須です' : null,
        (value) => !/\S+@\S+\.\S+/.test(value) ? '有効なメールアドレスを入力してください' : null
      ]
    }
  );
  
  const onSubmit = async (formData) => {
    console.log('ユーザー作成:', formData);
    await refetch(); // ユーザーリストを再取得
  };
  
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  
  return (
    <div>
      <h2>ユーザー管理</h2>
      
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <input
            type="text"
            placeholder="名前"
            value={values.name}
            onChange={(e) => setValue('name', e.target.value)}
            onBlur={() => setFieldTouched('name')}
          />
          {errors.name && <span className="error">{errors.name}</span>}
        </div>
        
        <div>
          <input
            type="email"
            placeholder="メールアドレス"
            value={values.email}
            onChange={(e) => setValue('email', e.target.value)}
            onBlur={() => setFieldTouched('email')}
          />
          {errors.email && <span className="error">{errors.email}</span>}
        </div>
        
        <button type="submit">ユーザー追加</button>
      </form>
      
      <div>
        <h3>ユーザー一覧</h3>
        {users?.map(user => (
          <div key={user.id}>
            {user.name} ({user.email})
          </div>
        ))}
      </div>
    </div>
  );
}

カスタムフックを使うと、コンポーネントがとてもシンプルになります。 複雑なロジックは全てフックに隠蔽されているんです。

ステップ4: エラーハンドリングとテストを実装しよう

4番目のステップは、堅牢なエラーハンドリングテストの実装です。

実際のアプリケーションでは、エラーは必ず発生します。 でも大丈夫です!適切な対処法を知っていれば、ユーザーに優しいアプリが作れます。

Error Boundaryの実装

まず、Error Boundaryを実装してみましょう。

import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { 
      hasError: false, 
      error: null, 
      errorInfo: null 
    };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    this.setState({
      error,
      errorInfo
    });
    
    // エラーログの送信
    this.logErrorToService(error, errorInfo);
  }
  
  logErrorToService = (error, errorInfo) => {
    // 実際のプロダクションではSentryなどのサービスに送信
    console.error('Error caught by boundary:', error, errorInfo);
    
    // APIにエラーログを送信
    fetch('/api/errors', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        error: error.toString(),
        errorInfo: errorInfo.componentStack,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent,
        url: window.location.href
      })
    }).catch(err => {
      console.error('Failed to log error:', err);
    });
  };
  
  handleReset = () => {
    this.setState({
      hasError: false,
      error: null,
      errorInfo: null
    });
  };
  
  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <div className="error-content">
            <h2>申し訳ございません</h2>
            <p>予期しないエラーが発生しました。</p>
            
            {process.env.NODE_ENV === 'development' && (
              <details className="error-details">
                <summary>エラー詳細(開発環境のみ)</summary>
                <pre>{this.state.error && this.state.error.toString()}</pre>
                <pre>{this.state.errorInfo.componentStack}</pre>
              </details>
            )}
            
            <div className="error-actions">
              <button onClick={this.handleReset}>
                再試行
              </button>
              <button onClick={() => window.location.reload()}>
                ページをリロード
              </button>
              <button onClick={() => window.location.href = '/'}>
                ホームに戻る
              </button>
            </div>
          </div>
        </div>
      );
    }
    
    return this.props.children;
  }
}

このError Boundaryは、エラーが発生した時の対処法を提供します。 エラーログの送信や、ユーザーへの適切な案内も含まれています。

関数型のError Boundaryも作れます。

import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert" className="error-fallback">
      <h2>エラーが発生しました</h2>
      <pre className="error-message">{error.message}</pre>
      <button onClick={resetErrorBoundary}>再試行</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, errorInfo) => {
        console.error('アプリケーションエラー:', error, errorInfo);
      }}
      onReset={() => {
        // エラー状態をリセットする際の処理
        window.location.reload();
      }}
    >
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/users" element={<Users />} />
        </Routes>
      </Router>
    </ErrorBoundary>
  );
}

実用的なテストの実装

次に、テストの実装方法を見てみましょう。 React Testing Libraryを使った実用的なテストを紹介します。

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import TodoList from './TodoList';

// コンポーネントのテスト
describe('TodoList', () => {
  const mockTodos = [
    { id: 1, text: 'タスク1', completed: false },
    { id: 2, text: 'タスク2', completed: true }
  ];
  
  const mockProps = {
    todos: mockTodos,
    onAddTodo: vi.fn(),
    onToggleTodo: vi.fn(),
    onDeleteTodo: vi.fn()
  };
  
  beforeEach(() => {
    vi.clearAllMocks();
  });
  
  test('todoリストが正しく表示される', () => {
    render(<TodoList {...mockProps} />);
    
    expect(screen.getByText('タスク1')).toBeInTheDocument();
    expect(screen.getByText('タスク2')).toBeInTheDocument();
  });
  
  test('新しいtodoを追加できる', async () => {
    const user = userEvent.setup();
    render(<TodoList {...mockProps} />);
    
    const input = screen.getByPlaceholderText('新しいタスクを入力');
    const addButton = screen.getByRole('button', { name: '追加' });
    
    await user.type(input, '新しいタスク');
    await user.click(addButton);
    
    expect(mockProps.onAddTodo).toHaveBeenCalledWith('新しいタスク');
  });
  
  test('todoの完了状態を切り替えできる', async () => {
    const user = userEvent.setup();
    render(<TodoList {...mockProps} />);
    
    const checkbox = screen.getByRole('checkbox', { name: /タスク1/ });
    await user.click(checkbox);
    
    expect(mockProps.onToggleTodo).toHaveBeenCalledWith(1);
  });
  
  test('todoを削除できる', async () => {
    const user = userEvent.setup();
    render(<TodoList {...mockProps} />);
    
    const deleteButton = screen.getAllByRole('button', { name: '削除' })[0];
    await user.click(deleteButton);
    
    expect(mockProps.onDeleteTodo).toHaveBeenCalledWith(1);
  });
  
  test('空のリストの場合、適切なメッセージが表示される', () => {
    render(<TodoList {...{ ...mockProps, todos: [] }} />);
    
    expect(screen.getByText('タスクがありません')).toBeInTheDocument();
  });
});

このテストは、実際のユーザーの操作をシミュレートしています。 テストを書くことで、機能が正しく動作することを確認できます。

カスタムフックのテストも書けます。

import { renderHook, act } from '@testing-library/react';
import useLocalStorage from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });
  
  test('初期値が正しく設定される', () => {
    const { result } = renderHook(() => 
      useLocalStorage('test-key', 'initial-value')
    );
    
    expect(result.current[0]).toBe('initial-value');
  });
  
  test('値を更新できる', () => {
    const { result } = renderHook(() => 
      useLocalStorage('test-key', 'initial-value')
    );
    
    act(() => {
      result.current[1]('updated-value');
    });
    
    expect(result.current[0]).toBe('updated-value');
    expect(localStorage.getItem('test-key')).toBe('"updated-value"');
  });
  
  test('既存のlocalStorageの値を読み込む', () => {
    localStorage.setItem('existing-key', '"existing-value"');
    
    const { result } = renderHook(() => 
      useLocalStorage('existing-key', 'default-value')
    );
    
    expect(result.current[0]).toBe('existing-value');
  });
});

テストを書くことで、リファクタリングも安心してできるようになります。

ステップ5: 実践的なプロジェクトを完成させよう

最後のステップは、これまで学んだスキルを統合した実践的なプロジェクトの完成です。

実際のプロダクションで使えるレベルのアプリケーションを作ってみましょう。 今回は、高機能なタスク管理アプリを例に説明します。

包括的なプロジェクト例:タスク管理アプリ

まず、プロジェクト全体の構造を見てみましょう。

import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';

// 1. 認証Context
const AuthContext = createContext();

function authReducer(state, action) {
  switch (action.type) {
    case 'LOGIN':
      return { ...state, user: action.payload, isAuthenticated: true };
    case 'LOGOUT':
      return { ...state, user: null, isAuthenticated: false };
    case 'UPDATE_PROFILE':
      return { ...state, user: { ...state.user, ...action.payload } };
    default:
      return state;
  }
}

function AuthProvider({ children }) {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
    isAuthenticated: false
  });
  
  // 認証状態の復元
  useEffect(() => {
    const savedUser = localStorage.getItem('user');
    if (savedUser) {
      dispatch({ type: 'LOGIN', payload: JSON.parse(savedUser) });
    }
  }, []);
  
  const login = async (credentials) => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });
      
      if (!response.ok) throw new Error('ログインに失敗しました');
      
      const user = await response.json();
      localStorage.setItem('user', JSON.stringify(user));
      dispatch({ type: 'LOGIN', payload: user });
      
      return user;
    } catch (error) {
      throw error;
    }
  };
  
  const logout = () => {
    localStorage.removeItem('user');
    dispatch({ type: 'LOGOUT' });
  };
  
  const value = {
    ...state,
    login,
    logout
  };
  
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

認証機能は、どのアプリにも必要な基本機能です。 ログイン状態の管理と、ローカルストレージへの保存を行っています。

次に、タスク管理のContextを見てみましょう。

// 2. タスク管理Context
const TaskContext = createContext();

function taskReducer(state, action) {
  switch (action.type) {
    case 'SET_TASKS':
      return { ...state, tasks: action.payload };
    case 'ADD_TASK':
      return { ...state, tasks: [...state.tasks, action.payload] };
    case 'UPDATE_TASK':
      return {
        ...state,
        tasks: state.tasks.map(task =>
          task.id === action.payload.id ? { ...task, ...action.payload } : task
        )
      };
    case 'DELETE_TASK':
      return {
        ...state,
        tasks: state.tasks.filter(task => task.id !== action.payload)
      };
    case 'SET_FILTER':
      return { ...state, filter: action.payload };
    case 'SET_SORT':
      return { ...state, sortBy: action.payload };
    default:
      return state;
  }
}

function TaskProvider({ children }) {
  const [state, dispatch] = useReducer(taskReducer, {
    tasks: [],
    filter: 'all',
    sortBy: 'dueDate'
  });
  
  const { user } = useAuth();
  
  // タスクの取得
  useEffect(() => {
    if (user) {
      fetchTasks();
    }
  }, [user]);
  
  const fetchTasks = async () => {
    try {
      const response = await fetch('/api/tasks', {
        headers: { 'Authorization': `Bearer ${user.token}` }
      });
      const tasks = await response.json();
      dispatch({ type: 'SET_TASKS', payload: tasks });
    } catch (error) {
      console.error('タスクの取得に失敗しました:', error);
    }
  };
  
  const addTask = async (taskData) => {
    try {
      const response = await fetch('/api/tasks', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${user.token}`
        },
        body: JSON.stringify(taskData)
      });
      
      const newTask = await response.json();
      dispatch({ type: 'ADD_TASK', payload: newTask });
      return newTask;
    } catch (error) {
      console.error('タスクの追加に失敗しました:', error);
      throw error;
    }
  };
  
  const updateTask = async (id, updates) => {
    try {
      const response = await fetch(`/api/tasks/${id}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${user.token}`
        },
        body: JSON.stringify(updates)
      });
      
      const updatedTask = await response.json();
      dispatch({ type: 'UPDATE_TASK', payload: updatedTask });
      return updatedTask;
    } catch (error) {
      console.error('タスクの更新に失敗しました:', error);
      throw error;
    }
  };
  
  const deleteTask = async (id) => {
    try {
      await fetch(`/api/tasks/${id}`, {
        method: 'DELETE',
        headers: { 'Authorization': `Bearer ${user.token}` }
      });
      
      dispatch({ type: 'DELETE_TASK', payload: id });
    } catch (error) {
      console.error('タスクの削除に失敗しました:', error);
      throw error;
    }
  };
  
  const value = {
    ...state,
    addTask,
    updateTask,
    deleteTask,
    setFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }),
    setSortBy: (sortBy) => dispatch({ type: 'SET_SORT', payload: sortBy })
  };
  
  return (
    <TaskContext.Provider value={value}>
      {children}
    </TaskContext.Provider>
  );
}

export const useTask = () => {
  const context = useContext(TaskContext);
  if (!context) {
    throw new Error('useTask must be used within TaskProvider');
  }
  return context;
};

このContextでは、タスクの全操作を管理しています。 API通信も含めて、タスクに関する全ての処理がここに集約されています。

メインアプリケーション

最後に、メインアプリケーションを見てみましょう。

// 3. メインアプリケーション
function App() {
  return (
    <ErrorBoundary
      FallbackComponent={({ error, resetErrorBoundary }) => (
        <div className="error-fallback">
          <h2>エラーが発生しました</h2>
          <pre>{error.message}</pre>
          <button onClick={resetErrorBoundary}>再試行</button>
        </div>
      )}
    >
      <AuthProvider>
        <Router>
          <div className="app">
            <Routes>
              <Route path="/login" element={<LoginPage />} />
              <Route path="/dashboard" element={
                <ProtectedRoute>
                  <TaskProvider>
                    <Dashboard />
                  </TaskProvider>
                </ProtectedRoute>
              } />
              <Route path="/" element={<Navigate to="/dashboard" replace />} />
            </Routes>
          </div>
        </Router>
      </AuthProvider>
    </ErrorBoundary>
  );
}

// 4. プロテクトされたルート
function ProtectedRoute({ children }) {
  const { isAuthenticated } = useAuth();
  
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  
  return children;
}

// 5. ダッシュボード
function Dashboard() {
  const { tasks, filter, sortBy } = useTask();
  
  const filteredTasks = useMemo(() => {
    let filtered = tasks;
    
    switch (filter) {
      case 'completed':
        filtered = tasks.filter(task => task.completed);
        break;
      case 'pending':
        filtered = tasks.filter(task => !task.completed);
        break;
      case 'overdue':
        filtered = tasks.filter(task => 
          !task.completed && new Date(task.dueDate) < new Date()
        );
        break;
      default:
        filtered = tasks;
    }
    
    return filtered.sort((a, b) => {
      switch (sortBy) {
        case 'dueDate':
          return new Date(a.dueDate) - new Date(b.dueDate);
        case 'priority':
          const priorityOrder = { high: 3, medium: 2, low: 1 };
          return priorityOrder[b.priority] - priorityOrder[a.priority];
        case 'title':
          return a.title.localeCompare(b.title);
        default:
          return 0;
      }
    });
  }, [tasks, filter, sortBy]);
  
  return (
    <div className="dashboard">
      <Header />
      <div className="dashboard-content">
        <Sidebar />
        <main className="main-content">
          <TaskList tasks={filteredTasks} />
        </main>
      </div>
    </div>
  );
}

export default App;

このアプリケーションは、プロダクションレベルの構成になっています。 認証、ルーティング、エラーハンドリング、状態管理など、全てが統合されています。

プロジェクトの構成とベストプラクティス

実際のプロジェクトでは、こんな構成にするのがおすすめです。

task-manager/
├── src/
│   ├── components/          # 再利用可能なコンポーネント
│   │   ├── common/         # Button, Input, Modal等
│   │   ├── forms/          # TaskForm, LoginForm等
│   │   └── layout/         # Header, Sidebar, Footer等
│   ├── pages/              # ページコンポーネント
│   │   ├── Dashboard.jsx
│   │   ├── Login.jsx
│   │   └── Settings.jsx
│   ├── hooks/              # カスタムフック
│   │   ├── useApi.js
│   │   ├── useLocalStorage.js
│   │   └── useForm.js
│   ├── contexts/           # React Context
│   │   ├── AuthContext.jsx
│   │   └── TaskContext.jsx
│   ├── utils/              # ユーティリティ関数
│   │   ├── dateUtils.js
│   │   ├── validation.js
│   │   └── api.js
│   ├── styles/             # スタイルファイル
│   │   ├── globals.css
│   │   └── components/
│   └── __tests__/          # テストファイル
│       ├── components/
│       ├── hooks/
│       └── utils/
├── public/
├── package.json
└── README.md

このように、機能別に整理することで、保守性の高いプロジェクトが作れます。

まとめ:React中級者への道のり

React初心者から中級者へステップアップするための5つのステップについて解説しました。

5つのステップまとめ

  1. 高度な状態管理:useReducer、Context APIの習得
  2. パフォーマンス最適化:memo、useCallback、useMemo の活用
  3. カスタムフック作成:ロジックの再利用とカプセル化
  4. エラーハンドリングとテスト:堅牢なアプリケーションの構築
  5. 実践的プロジェクト:統合的なスキルの活用

これらのステップを順序立てて習得することで、確実にReact中級者レベルに到達できます。

成功のポイント

重要なのは、各ステップで実際に手を動かして実装することです。 読んだだけでは身につきません。

実際にコードを書いて、エラーと戦って、動いたときの喜びを味わってください。 その積み重ねが、確実なスキルアップにつながります。

次のステップ

この記事で紹介したステップを参考に、段階的にReactスキルを向上させてみてください。 きっと、自信を持って「React中級者」と言えるレベルに到達できるはずです。

がんばってください!応援しています。

関連記事