React useMemoの実務活用ガイド:パフォーマンス最適化の実装パターン

React / Next.js

React useMemoの実務活用ガイド:パフォーマンス最適化の実装パターン

はじめに

React開発の現場では、コンポーネントの再レンダリングによるパフォーマンス低下は多くの開発者が直面する課題です。特に大規模なアプリケーションでは、計算コストが高い処理や複雑なデータ変換が何度も実行されることで、ユーザー体験が大きく損なわれます。このような場面で活躍するのがuseMemoフックです。本記事では、教科書的な説明ではなく、実務で実際に使用されるパターンに焦点を当てて解説します。

useMemoの基礎理解

useMemoは、Reactの標準フックの一つで、メモ化(memoization)を通じて計算結果をキャッシュするために設計されています。コンポーネントの再レンダリング時に、依存配列の値が変わっていなければ、以前の計算結果を再利用します。

基本的な構文は以下の通りです。

const memoizedValue = useMemo(() => {
  return expensiveCalculation(a, b);
}, [a, b]);

第一引数は計算を実行する関数、第二引数は依存配列です。依存配列に含まれる値が変わった場合にのみ、関数が再実行されます。

業務でのユースケース

ユースケース1:フィルタリングと並べ替え処理

ECサイトの商品一覧画面で、数千件の商品データに対してリアルタイムでフィルタリングと並べ替えを行う場面を想像してください。ユーザーがフィルター条件を変更するたびに、全商品データを再処理するのは非効率です。このような場合、useMemoが活躍します。

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  rating: number;
}

interface FilterCondition {
  category: string;
  priceRange: [number, number];
  minRating: number;
  sortBy: 'price' | 'rating' | 'name';
}

const ProductList: React.FC<{
  products: Product[];
  filters: FilterCondition;
}> = ({ products, filters }) => {
  const filteredAndSortedProducts = useMemo(() => {
    console.log('フィルタリング処理を実行中...');
    
    let result = products.filter(product => {
      return (
        product.category === filters.category &&
        product.price >= filters.priceRange[0] &&
        product.price <= filters.priceRange[1] &&
        product.rating >= filters.minRating
      );
    });

    // ソート処理
    result.sort((a, b) => {
      switch (filters.sortBy) {
        case 'price':
          return a.price - b.price;
        case 'rating':
          return b.rating - a.rating;
        case 'name':
          return a.name.localeCompare(b.name);
        default:
          return 0;
      }
    });

    return result;
  }, [products, filters.category, filters.priceRange, filters.minRating, filters.sortBy]);

  return (
    
{filteredAndSortedProducts.map(product => ( ))}
); };

このコードでは、フィルター条件やソート条件が変わった時だけフィルタリング処理が実行されます。親コンポーネントの他の状態が変わっても、不要な再計算は避けられます。

ユースケース2:複雑なオブジェクトの変換

APIから取得したデータを、画面表示用に複雑な変換を行う場面も実務ではよくあります。例えば、ユーザーデータをフロントエンド用のビューモデルに変換する場合です。

interface UserData {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
  joinDate: string;
  lastLoginDate: string;
  postsCount: number;
  followersCount: number;
}

interface UserViewModel {
  displayName: string;
  initials: string;
  membershipDuration: string;
  daysSinceLogin: number;
  engagementScore: number;
  isActive: boolean;
}

const calculateEngagementScore = (postsCount: number, followersCount: number): number => {
  return (postsCount * 2 + followersCount * 0.5) / 10;
};

const calculateMembershipDuration = (joinDate: string): string => {
  const join = new Date(joinDate);
  const now = new Date();
  const years = Math.floor((now.getTime() - join.getTime()) / (1000 * 60 * 60 * 24 * 365));
  return years > 0 ? `${years}年` : '1年未満';
};

const UserProfile: React.FC<{ userData: UserData }> = ({ userData }) => {
  const viewModel = useMemo(() => {
    const lastLogin = new Date(userData.lastLoginDate);
    const now = new Date();
    const daysSinceLogin = Math.floor((now.getTime() - lastLogin.getTime()) / (1000 * 60 * 60 * 24));

    return {
      displayName: `${userData.firstName} ${userData.lastName}`,
      initials: `${userData.firstName[0]}${userData.lastName[0]}`.toUpperCase(),
      membershipDuration: calculateMembershipDuration(userData.joinDate),
      daysSinceLogin,
      engagementScore: calculateEngagementScore(userData.postsCount, userData.followersCount),
      isActive: daysSinceLogin < 30,
    } as UserViewModel;
  }, [userData]);

  return (
    

{viewModel.displayName}

イニシャル: {viewModel.initials}

会員期間: {viewModel.membershipDuration}

最後のログイン: {viewModel.daysSinceLogin}日前

エンゲージメントスコア: {viewModel.engagementScore.toFixed(2)}

ステータス: {viewModel.isActive ? 'アクティブ' : 'インアクティブ'}

); };

ユースケース3:依存配列の最適化

実務では、依存配列に含める値の選択が重要です。以下は、useCallbackと組み合わせた実際の例です。

interface SearchParams {
  query: string;
  filters: Record;
  limit: number;
}

const SearchResults: React.FC<{ params: SearchParams }> = ({ params }) => {
  // フィルターオブジェクトの参照が毎回変わるため、
  // JSON文字列化して比較することで無駄な再計算を避ける
  const filtersKey = useMemo(() => JSON.stringify(params.filters), [params.filters]);

  const results = useMemo(() => {
    console.log('検索処理を実行中...');
    // 実際の検索ロジック
    return performSearch(params.query, params.filters, params.limit);
  }, [params.query, filtersKey, params.limit]);

  return (
    
{results.map(result => ( ))}
); };

実装パターン:実務コード集

パターン1:テーブルデータの集計処理

販売管理システムで、毎月の売上データを集計して表示する場面です。

interface SalesRecord {
  id: number;
  date: string;
  amount: number;
  productCategory: string;
  region: string;
}

interface SalesAggregation {
  totalSales: number;
  byCategory: Record;
  byRegion: Record;
  topCategories: Array<{ category: string; amount: number }>;
  averageSale: number;
}

const SalesDashboard: React.FC<{ records: SalesRecord[] }> = ({ records }) => {
  const aggregated = useMemo(() => {
    const result: SalesAggregation = {
      totalSales: 0,
      byCategory: {},
      byRegion: {},
      topCategories: [],
      averageSale: 0,
    };

    records.forEach(record => {
      result.totalSales += record.amount;
      result.byCategory[record.productCategory] = 
        (result.byCategory[record.productCategory] || 0) + record.amount;
      result.byRegion[record.region] = 
        (result.byRegion[record.region] || 0) + record.amount;
    });

    result.averageSale = records.length > 0 ? result.totalSales / records.length : 0;

    result.topCategories = Object.entries(result.byCategory)
      .map(([category, amount]) => ({ category, amount }))
      .sort((a, b) => b.amount - a.amount)
      .slice(0, 5);

    return result;
  }, [records]);

  return (
    

売上集計

総売上: ¥{aggregated.totalSales.toLocaleString()}

平均売上: ¥{aggregated.averageSale.toLocaleString()}

売上トップカテゴリー

    {aggregated.topCategories.map(item => (
  • {item.category}: ¥{item.amount.toLocaleString()}
  • ))}
); };

パターン2:GraphQL クエリの動的生成

フィルター条件に応じて、GraphQLクエリを動的に生成する場合もuseMemoが役立ちます。

interface GraphQLFilter {
  field: string;
  operator: 'eq' | 'gt' | 'lt' | 'contains';
  value: string | number;
}

const buildGraphQLQuery = (filters: GraphQLFilter[]): string => {
  const conditions = filters
    .map(f => {
      switch (f.operator) {
        case 'eq':
          return `${f.field}: "${f.value}"`;
        case 'gt':
          return `${f.field}_gt: ${f.value}`;
        case 'lt':
          return `${f.field}_lt: ${f.value}`;
        case 'contains':
          return `${f.field}_contains: "${f.value}"`;
        default:
          return '';
      }
    })
    .filter(Boolean)
    .join(', ');

  return `query {
    items(${conditions}) {
      id
      name
      createdAt
    }
  }`;
};

const ItemsList: React.FC<{ filters: GraphQLFilter[] }> = ({ filters }) => {
  const query = useMemo(() => {
    return buildGraphQLQuery(filters);
  }, [filters]);

  const { data, loading } = useQuery(query);

  return (
    
{loading &&

読み込み中...

} {data && (
    {data.items.map((item: any) => (
  • {item.name}
  • ))}
)}
); };

よくある応用パターン

パターン1:useCallbackとの組み合わせ

子コンポーネントにコールバック関数を渡す場合、useCallbackとuseMemoを組み合わせることで、不要な再レンダリングを防げます。

const ParentComponent: React.FC<{ items: any[] }> = ({ items }) => {
  const [selectedIds, setSelectedIds] = React.useState([]);

  const filteredItems = useMemo(() => {
    return items.filter(item => !selectedIds.includes(item.id));
  }, [items, selectedIds]);

  const handleSelect = useCallback((id: number) => {
    setSelectedIds(prev => [...prev, id]);
  }, []);

  return (
    
{filteredItems.map(item => ( ))}
); };

パターン2:配列の正規化

APIから取得したネストされたデータを正規化する場合も、useMemoが効果的です。

interface Comment {
  id: number;
  text: string;
  userId: number;
}

interface Post {
  id: number;
  title: string;
  comments: Comment[];
}

interface NormalizedData {
  posts: Record>;
  comments: Record;
  postCommentIds: Record;
}

const normalizePostData = (posts: Post[]): NormalizedData => {
  const normalized: NormalizedData = {
    posts: {},
    comments: {},
    postCommentIds: {},
  };

  posts.forEach(post => {
    normalized.posts[post.id] = {
      id: post.id,
      title: post.title,
    };
    normalized.postCommentIds[post.id] = [];

    post.comments.forEach(comment => {
      normalized.comments[comment.id] = comment;
      normalized.postCommentIds[post.id].push(comment.id);
    });
  });

  return normalized;
};

const PostFeed: React.FC<{ posts: Post[] }> = ({ posts }) => {
  const normalized = useMemo(() => normalizePostData(posts), [posts]);

  return (
    
{Object.values(normalized.posts).map(post => ( normalized.comments[id] )} /> ))}
); };

実装時の注意点

注意点1:過度なメモ化の避け方

すべての計算をuseMemoでラップするべきではありません。メモ化自体にもコスト(依存配列の比較、メモリ使用)があるため、実際にパフォーマンス問題がある場合に限定すべきです。

// ❌ 不要なメモ化の例
const count = useMemo(() => items.length, [items]);

// ✅ 単純な計算は直接行う
const count = items.length;

// ✅ 複雑な処理の場合のみメモ化
const sorted = useMemo(() => {
  return items
    .filter(item => item.active)
    .sort((a, b) => b.score - a.score)
    .map(item => ({ ...item, rank: items.indexOf(item) + 1 }));
}, [items]);

注意点2:依存配列の正確性

依存配列を誤ると、古いデータが表示され続けるバグが発生します。lintルール「exhaustive-deps」を有効にすることを推奨します。

// ❌ 依存配列が不完全な例
const result = useMemo(() => {
  return calculateResult(a, b, c); // c を使用しているのに...
}, [a, b]); // c が依存配列に含まれていない

// ✅ すべての依存値を含める
const result = useMemo(() => {
  return calculateResult(a, b, c);
}, [a, b, c]);

注意点3:参照型の依存値の扱い

オブジェクトや配列を依存配列に含める場合、毎回新しい参照が生成されるとuseMemoが機能しません。

// ❌ 毎回新しいオブジェクトが生成される
const Component = ({ config: { theme, size } }) => {
  const style = useMemo(() => ({
    background: theme,
    width: size,
  }), [config]); // configが毎回新しい参照
};

// ✅ 依存値を最小化する
const Component = ({ theme, size }) => {
  const style = useMemo(() => ({
    background: theme,
    width: size,
  }), [theme, size]);
};

注意点4:ローカルストレージやAPIとの組み合わせ

副作用を持つ操作(データベースアクセス、APIコール)はuseMemoではなく、useEffectと組み合わせるべきです。

// ❌ useMemoでAPIコールをしない
const data = useMemo(() => {
  return fetch('/api/data').then(r => r.json()); // 副作用がある
}, []);

// ✅ useEffectを使用する
const [data, setData] = React.useState(null);

React.useEffect(() => {
  fetch('/api/data')
    .then(r => r.json())
    .then(setData);
}, []);

パフォーマンス測定の実務方法

useMemoの効果を測定する方法を紹介します。

const useRenderCount = (componentName: string) => {
  const renderCount = React.useRef(0);

  React.useEffect(() => {
    renderCount.current++;
    console.log(`${componentName} rendered ${renderCount.current} times`);
  });

  return renderCount.current;
};

const PerformanceOptimizedComponent: React.FC<{ data: any[] }> = ({ data }) => {
  const renderCount = useRenderCount('PerformanceOptimizedComponent');

  const processedData = useMemo(() => {
    const start = performance.now();
    const result = data
      .filter(item => item.active)
      .map(item => ({ ...item, processed: true }));
    const end = performance.now();
    console.log(`処理時間: ${end - start}ms`);
    return result;
  }, [data]);

  return (
    

レンダリング回数: {renderCount}

処理済みアイテム数: {processedData.length}

); };

まとめ

useMemoは、React アプリケーションのパフォーマンス最適化に不可欠なツールです。実務での効果的な使用には、以下のポイントが重要です。

  • 必要な場面を見極める:すべての計算をメモ化するのではなく、実際にパフォーマンス問題がある箇所に限定する
  • 依存配列を正確に設定:lintルールを活用し、すべての依存値を含める
  • 参照型の値は慎重に扱う:オブジェクトや配列の参照変更に注意し、必要に応じてJSON化やuseMemoでラップする
  • 複雑な計算処理に焦点を当てる:フィルタリング、ソート、データ変換など、計算コストが高い処理が最適な候補
  • useCallbackやReact.memoと組み合わせる:より効果的なパフォーマンス最適化が実現できる
  • 実際のパフォーマンス測定を行う:推測ではなく、測定ツールで効果を検証する

useMemoは正しく使えば強力な最適化ツールになりますが、誤用するとバグの原因になります。実務では常にテストを書き、パフォーマンス改善が実際に起こっているかを確認することが重要です。

タイトルとURLをコピーしました