0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useMemoで大きな配列の処理を最適化

Posted at

なんでuseMemoを使ったの?

データダッシュボードを作っていたとき、大きな配列をフィルタリング、ソート、集計して表示する機能を実装した。最初は素直に処理を書いたら、入力やソート条件が変わるたびに再レンダリングが走ってUIがカクつく問題が発生。特にデータ量が多いと顕著で、ユーザビリティが落ちるのが悩みだった。そこで、useMemoを使って計算を最適化したら、パフォーマンスが劇的に改善sしたので、useMemoの使い方と、実際に大きな配列を扱った場合の実例をまとめる。

やりたかったこと

  • 目的: 売上データ(数千件)の配列を、ユーザーの入力(フィルタやソート条件)に基づいてフィルタリング、ソート、集計し、テーブルで表示。
  • 要件:
    • フィルタ: ユーザーが指定した期間やカテゴリでデータを絞り込み。
    • ソート: 売上額や日付で並べ替え。
    • 集計: フィルタ後のデータの合計や平均を計算。
    • パフォーマンス: 大きなデータでもサクサク動くUI。
  • なぜuseMemo?: フィルタリングやソートは計算コストが高く、不要な再計算を避けたかった。useMemoは依存配列が変わらない限り結果をキャッシュしてくれるので、ピッタリだった。

実装の流れ

バックエンドはFastAPI(Python)で売上データを返すAPIを用意。フロントエンドはTypeScript+Reactで、テーブル表示とフィルタリング・ソート機能を実装。useMemoを使って重い処理を最適化した。

1. バックエンド: 売上データAPI(FastAPI)

FastAPIでダミーの売上データを返すエンドポイントを作成。実際はDBから取得する想定だが、簡略化のためハードコード。

from fastapi import FastAPI
from pydantic import BaseModel
from typing import List
import random
from datetime import datetime, timedelta

app = FastAPI()

class Sale(BaseModel):
    id: int
    date: str
    category: str
    amount: float

# ダミーデータ生成
sales_data = [
    {"id": i, "date": (datetime.now() - timedelta(days=random.randint(1, 365))).isoformat(), 
     "category": random.choice(["Electronics", "Clothing", "Books"]), 
     "amount": random.uniform(10, 1000)}
    for i in range(5000)  # 5000件
]

@app.get("/sales", response_model=List[Sale])
async def get_sales():
    return sales_data

2. フロントエンド: useMemoでデータ処理(TypeScript+React)

React QueryのuseQueryでデータを取得し、useMemoでフィルタリング、ソート、集計を最適化。以下は実装例。

import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

// 売上データの型
interface Sale {
  id: number;
  date: string;
  category: string;
  amount: number;
}

// APIリクエスト
const fetchSales = async (): Promise<Sale[]> => {
  const response = await axios.get('/sales');
  return response.data;
};

const SalesDashboard: React.FC = () => {
  const [filterCategory, setFilterCategory] = useState<string>('All');
  const [sortBy, setSortBy] = useState<'date' | 'amount'>('date');
  const [dateRange, setDateRange] = useState<{ start: string; end: string }>({
    start: '',
    end: '',
  });

  // データ取得
  const { data: sales = [] } = useQuery({ queryKey: ['sales'], queryFn: fetchSales });

  // フィルタリング、ソート、集計をuseMemoで最適化
  const processedData = useMemo(() => {
    let filtered = sales;

    // フィルタ: カテゴリ
    if (filterCategory !== 'All') {
      filtered = filtered.filter(sale => sale.category === filterCategory);
    }

    // フィルタ: 日付範囲
    if (dateRange.start && dateRange.end) {
      filtered = filtered.filter(sale => {
        const saleDate = new Date(sale.date);
        return saleDate >= new Date(dateRange.start) && saleDate <= new Date(dateRange.end);
      });
    }

    // ソート
    const sorted = [...filtered].sort((a, b) => {
      if (sortBy === 'amount') {
        return b.amount - a.amount; // 降順
      }
      return new Date(b.date).getTime() - new Date(a.date).getTime(); // 降順
    });

    // 集計
    const totalAmount = sorted.reduce((sum, sale) => sum + sale.amount, 0);
    const averageAmount = sorted.length ? totalAmount / sorted.length : 0;

    return { sortedData: sorted, totalAmount, averageAmount };
  }, [sales, filterCategory, sortBy, dateRange]);

  return (
    <div>
      <h2>売上ダッシュボード</h2>
      <div>
        <label>カテゴリ: </label>
        <select onChange={(e) => setFilterCategory(e.target.value)}>
          <option value="All">すべて</option>
          <option value="Electronics">Electronics</option>
          <option value="Clothing">Clothing</option>
          <option value="Books">Books</option>
        </select>
      </div>
      <div>
        <label>ソート: </label>
        <select onChange={(e) => setSortBy(e.target.value as 'date' | 'amount')}>
          <option value="date">日付</option>
          <option value="amount">売上額</option>
        </select>
      </div>
      <div>
        <label>開始日: </label>
        <input
          type="date"
          onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
        />
        <label>終了日: </label>
        <input
          type="date"
          onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
        />
      </div>
      <h3>集計</h3>
      <p>合計売上: ${processedData.totalAmount.toFixed(2)}</p>
      <p>平均売上: ${processedData.averageAmount.toFixed(2)}</p>
      <h3>データ</h3>
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>日付</th>
            <th>カテゴリ</th>
            <th>売上額</th>
          </tr>
        </thead>
        <tbody>
          {processedData.sortedData.map(sale => (
            <tr key={sale.id}>
              <td>{sale.id}</td>
              <td>{new Date(sale.date).toLocaleDateString()}</td>
              <td>{sale.category}</td>
              <td>${sale.amount.toFixed(2)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default SalesDashboard;

使ったライブラリ

  • @tanstack/react-query: データ取得用。
  • axios: APIリクエスト。TypeScriptと相性良い。
  • @types/axios: TypeScriptの型定義。

useMemoの使い方のポイント

useMemoの基本と、プロジェクトでの学びを解説。

1. 基本構造

import { useMemo } from 'react';

const memoizedValue = useMemo(() => {
  // 重い計算処理
  return computeExpensiveValue(dependency1, dependency2);
}, [dependency1, dependency2]);
  • 第一引数: 計算ロジックを関数で定義。戻り値がキャッシュされる。
  • 第二引数: 依存配列。配列内の値が変わったときだけ再計算。
  • 用途: 計算コストが高い処理(フィルタリング、ソート、集計など)を最適化。

2. なぜuseMemoが必要だった?

今回のケースでは、5000件の売上データをフィルタリング、ソート、集計していた。useMemoなしだと、入力や状態変更(filterCategorysortByの変更)ごとに全処理が再実行され、UIがカクついた。useMemoで依存配列([sales, filterCategory, sortBy, dateRange])を指定すると、必要なときだけ計算されてパフォーマンスが向上。

3. 依存配列の設計

依存配列に含める変数は、計算ロジックで使うものだけ。たとえば、processedDataが依存しない変数(無関係な状態)を入れると無駄な再計算が発生する。逆に、必要な依存を忘れるとバグるので注意。

ハマったポイントと対策

  1. 依存配列のミス
    最初、dateRangeを依存配列に入れ忘れて、フィルタが反映されないバグが発生。useMemoの依存配列はロジックで使う全変数をチェックする必要がある。ESLintのreact-hooks/exhaustive-depsルールが助けてくれた。

  2. パフォーマンスチューニング
    5000件のデータでソートが重かった。特にDateオブジェクトの変換がボトルネックに。useMemoでキャッシュしても、初回計算が遅いので、データ量が多い場合はサーバー側で前処理する案を検討中。

  3. 型エラー
    TypeScriptでprocessedDataの型を明示しないと、VS Codeがエラーを吐いた。以下のように型を定義したら解決した

    const processedData = useMemo<{
      sortedData: Sale[];
      totalAmount: number;
      averageAmount: number;
    }>(() => { /* 処理 */ }, [sales, filterCategory, sortBy, dateRange]);
    
  4. スプレッド演算子の注意
    ソート時に[...filtered]で配列をコピーしたが、データ量が多いとこれもコストに。パフォーマンスをさらに追求するなら、ライブラリ(LodashのsortBy)を検討。

使ってみて良かった点

  • パフォーマンス向上: useMemoのおかげで、フィルタやソートの再計算が減り、UIがサクサク動くようになった。5000件でもストレスなし。
  • コードのシンプルさ: フィルタリング、ソート、集計を1つのuseMemoでまとめて管理でき、コードがスッキリ。
  • TypeScriptとの相性: 型定義をちゃんとすれば、型の恩恵でバグを減らせた。
  • ユーザビリティ: フィルタやソートが変わっても即座に反映され使いやすくなった。

課題と次にやりたいこと

  • サーバー側処理の検討: データ量がさらに増えたら、フィルタリングやソートをFastAPI側でやるほうが効率的かも。SQLで処理する案を模索中。
  • ページネーション: 5000件を全部表示すると重いので、ページネーションや仮想スクロールを追加したい。
  • キャッシュ戦略: useMemoはコンポーネント内限定なので、複数コンポーネントで共有するならReact Queryのキャッシュを活用予定。

まとめ

ReactのuseMemoを使って、大きな売上データのフィルタリング、ソート、集計を最適化した。大量のデータでもサクサク動くUIが作れて、チームの作業効率が上がった。依存配列のミスや型定義でハマったけど、TypeScriptとESLintのおかげで解決できた。useMemoは計算コストが高い処理にめっちゃハマるので、データ処理が多い場面では最適。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?