なんで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
なしだと、入力や状態変更(filterCategory
やsortBy
の変更)ごとに全処理が再実行され、UIがカクついた。useMemo
で依存配列([sales, filterCategory, sortBy, dateRange]
)を指定すると、必要なときだけ計算されてパフォーマンスが向上。
3. 依存配列の設計
依存配列に含める変数は、計算ロジックで使うものだけ。たとえば、processedData
が依存しない変数(無関係な状態)を入れると無駄な再計算が発生する。逆に、必要な依存を忘れるとバグるので注意。
ハマったポイントと対策
-
依存配列のミス
最初、dateRange
を依存配列に入れ忘れて、フィルタが反映されないバグが発生。useMemo
の依存配列はロジックで使う全変数をチェックする必要がある。ESLintのreact-hooks/exhaustive-deps
ルールが助けてくれた。 -
パフォーマンスチューニング
5000件のデータでソートが重かった。特にDate
オブジェクトの変換がボトルネックに。useMemo
でキャッシュしても、初回計算が遅いので、データ量が多い場合はサーバー側で前処理する案を検討中。 -
型エラー
TypeScriptでprocessedData
の型を明示しないと、VS Codeがエラーを吐いた。以下のように型を定義したら解決したconst processedData = useMemo<{ sortedData: Sale[]; totalAmount: number; averageAmount: number; }>(() => { /* 処理 */ }, [sales, filterCategory, sortBy, dateRange]);
-
スプレッド演算子の注意
ソート時に[...filtered]
で配列をコピーしたが、データ量が多いとこれもコストに。パフォーマンスをさらに追求するなら、ライブラリ(LodashのsortBy
)を検討。
使ってみて良かった点
-
パフォーマンス向上:
useMemo
のおかげで、フィルタやソートの再計算が減り、UIがサクサク動くようになった。5000件でもストレスなし。 -
コードのシンプルさ: フィルタリング、ソート、集計を1つの
useMemo
でまとめて管理でき、コードがスッキリ。 - TypeScriptとの相性: 型定義をちゃんとすれば、型の恩恵でバグを減らせた。
- ユーザビリティ: フィルタやソートが変わっても即座に反映され使いやすくなった。
課題と次にやりたいこと
- サーバー側処理の検討: データ量がさらに増えたら、フィルタリングやソートをFastAPI側でやるほうが効率的かも。SQLで処理する案を模索中。
- ページネーション: 5000件を全部表示すると重いので、ページネーションや仮想スクロールを追加したい。
-
キャッシュ戦略:
useMemo
はコンポーネント内限定なので、複数コンポーネントで共有するならReact Queryのキャッシュを活用予定。
まとめ
ReactのuseMemo
を使って、大きな売上データのフィルタリング、ソート、集計を最適化した。大量のデータでもサクサク動くUIが作れて、チームの作業効率が上がった。依存配列のミスや型定義でハマったけど、TypeScriptとESLintのおかげで解決できた。useMemo
は計算コストが高い処理にめっちゃハマるので、データ処理が多い場面では最適。