Reactの再レンダリングとは?無駄な処理を防ぐ基礎知識
Reactの再レンダリングの仕組みを理解して無駄な処理を防ぐ方法を解説。React.memo、useMemo、useCallbackなどの最適化手法を実例とともに詳しく説明します。
みなさん、Reactアプリを作っていて、こんな悩みありませんか?
「画面の動作が遅くて困ってる」 「入力するたびにカクカクしちゃう」 「なんだか必要以上に処理が走っている気がする」
実は、そのほとんどはReactの再レンダリングが原因なんです。
この記事では、再レンダリングの仕組みから、実際に使える最適化方法まで、実際のコード例と一緒に分かりやすく解説していきます。 React.memo、useMemo、useCallbackといった最適化フックも、一つずつ丁寧に説明しますよ。
一緒に、サクサク動くReactアプリを作れるようになりましょう!
そもそもReactの再レンダリングって何?
まずは基本から。 Reactの再レンダリングがどんな仕組みで動いているのか、理解していきましょう。
再レンダリングの基本的な流れ
再レンダリングとは、コンポーネントが新しい内容でもう一度描画されることです。
簡単に言うと、画面の一部を「書き直し」する作業ですね。
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
console.log('Counter コンポーネントがレンダリングされました');
const handleClick = () => {
setCount(count + 1);
};
return (
<div>
<p>カウント: {count}</p>
<button onClick={handleClick}>増加</button>
</div>
);
}
このコンポーネントでボタンを押すと、こんな流れで再レンダリングが起こります。
- ボタンをクリック
setCount
でstateが更新される- Reactが「あ、変更があった!」と気づく
- コンポーネント関数がもう一度実行される
- 新しいJSXが作られる
- 画面(DOM)が更新される
コンソールを見ると、ボタンを押すたびに「Counter コンポーネントがレンダリングされました」と表示されるはずです。
どんなときに再レンダリングが起こるの?
再レンダリングが発生する主なパターンは3つあります。
function MyComponent({ user, theme }) {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
console.log('MyComponent がレンダリングされました');
// 1. 自分のstateが変更された時
const handleCountChange = () => {
setCount(count + 1); // この変更で再レンダリング
};
const handleNameChange = (e) => {
setName(e.target.value); // この変更でも再レンダリング
};
return (
<div>
<p>カウント: {count}</p>
<p>名前: {name}</p>
<p>ユーザー: {user.name}</p>
<p>テーマ: {theme}</p>
<button onClick={handleCountChange}>カウント増加</button>
<input value={name} onChange={handleNameChange} />
</div>
);
}
上のコードでは、count
やname
のstateが変わると再レンダリングが起こります。
さらに、親コンポーネントからpropsが変わったときも再レンダリングされます。
function App() {
const [user, setUser] = useState({ name: '太郎' });
const [theme, setTheme] = useState('light');
return (
<div>
<MyComponent user={user} theme={theme} />
<button onClick={() => setUser({ name: '花子' })}>
ユーザー変更 {/* 2. propsが変更された時 */}
</button>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
テーマ変更 {/* 3. propsが変更された時 */}
</button>
</div>
);
}
親が変わると子も一緒に変わっちゃう
実は、親コンポーネントが再レンダリングされると、子コンポーネントも自動的に再レンダリングされるんです。
これが、パフォーマンス問題の大きな原因になることが多いです。
function ParentComponent() {
const [parentCount, setParentCount] = useState(0);
console.log('ParentComponent がレンダリングされました');
return (
<div>
<p>親のカウント: {parentCount}</p>
<button onClick={() => setParentCount(parentCount + 1)}>
親のカウント増加
</button>
{/* 親が再レンダリングされると、子も再レンダリングされる */}
<ChildComponent />
<AnotherChildComponent />
</div>
);
}
function ChildComponent() {
console.log('ChildComponent がレンダリングされました');
return <div>子コンポーネント</div>;
}
function AnotherChildComponent() {
console.log('AnotherChildComponent がレンダリングされました');
return <div>別の子コンポーネント</div>;
}
この例では、親のparentCount
が変わると、子コンポーネントたちは何も変更がないのに一緒に再レンダリングされちゃいます。
コンソールを確認すると、親のボタンを押すたびに子コンポーネントの「レンダリングされました」メッセージも表示されるはずです。
再レンダリングで起こる問題
不要な再レンダリングが多すぎると、いろんな問題が起こります。 具体的にどんなことが起きるのか、見ていきましょう。
アプリがもっさり重くなる
重い処理を含むコンポーネントが不要に再レンダリングされると、アプリ全体が重くなってしまいます。
// 重い処理を含むコンポーネント
function ExpensiveComponent({ data }) {
console.log('ExpensiveComponent がレンダリングされました');
// 重い計算処理(実際には複雑な処理)
const expensiveValue = data.reduce((sum, item) => {
// 意図的に重い処理をシミュレート
for (let i = 0; i < 1000000; i++) {
sum += item.value;
}
return sum;
}, 0);
return (
<div>
<h3>重い処理の結果</h3>
<p>合計値: {expensiveValue}</p>
</div>
);
}
上のコンポーネントは、毎回レンダリングされるたびに重い計算を実行します。
function App() {
const [count, setCount] = useState(0);
const [data] = useState([
{ value: 1 }, { value: 2 }, { value: 3 }
]);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
カウント増加
</button>
{/* countが変更されるたびに重い処理が実行される */}
<ExpensiveComponent data={data} />
</div>
);
}
この場合、count
ボタンを押すたびにExpensiveComponent
も再レンダリングされて、重い計算が無駄に実行されちゃいます。
data
は全然変わってないのに、毎回同じ計算をやり直してるんです。
これじゃあ、アプリが重くなるのも当然ですよね。
入力がカクカクして使いにくくなる
検索フォームなどでよく起こる問題です。
// 入力フォームでの問題例
function SearchForm() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
const handleSearch = (e) => {
setSearchTerm(e.target.value);
// 検索処理(簡略化)
const filtered = mockData.filter(item =>
item.name.includes(e.target.value)
);
setResults(filtered);
};
return (
<div>
<input
value={searchTerm}
onChange={handleSearch}
placeholder="検索..."
/>
{/* 毎回全ての結果が再レンダリングされる */}
<SearchResults results={results} />
</div>
);
}
function SearchResults({ results }) {
console.log('SearchResults がレンダリングされました');
return (
<div>
{results.map(result => (
<SearchResultItem key={result.id} item={result} />
))}
</div>
);
}
function SearchResultItem({ item }) {
console.log(`SearchResultItem ${item.id} がレンダリングされました`);
return (
<div>
<h4>{item.name}</h4>
<p>{item.description}</p>
</div>
);
}
この例だと、文字を1つ入力するたびに全ての検索結果アイテムが再レンダリングされます。
結果が100件あったら、1文字入力するだけで100個のコンポーネントが再描画される計算です。 これじゃあ、入力がカクカクしちゃいますよね。
メモリをどんどん消費しちゃう
再レンダリングのたびに新しいオブジェクトや関数を作っていると、メモリ使用量がどんどん増えていきます。
function ProblematicComponent() {
const [count, setCount] = useState(0);
// 毎回新しいオブジェクトが作成される
const config = {
theme: 'dark',
language: 'ja',
apiEndpoint: 'https://api.example.com'
};
// 毎回新しい関数が作成される
const handleClick = () => {
console.log('クリックされました');
};
// 毎回新しい配列が作成される
const items = ['item1', 'item2', 'item3'];
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>増加</button>
<ChildComponent
config={config}
onAction={handleClick}
items={items}
/>
</div>
);
}
上のコードでは、再レンダリングのたびにconfig
、handleClick
、items
が新しく作られます。
古いものは捨てられるので、結果的にメモリの無駄遣いになってしまいます。
でも大丈夫です! 次のセクションから、これらの問題を解決する方法を一つずつ見ていきましょう。
React.memoで不要な再レンダリングを防ごう
React.memoを使うと、propsが変わらない限り再レンダリングをスキップできます。
まずは基本的な使い方から見ていきましょう。
React.memoの基本的な使い方
React.memoでコンポーネントを包むだけで、propsが同じなら再レンダリングを防げます。
import React, { memo, useState } from 'react';
// React.memo で包むことで最適化
const OptimizedComponent = memo(function MyComponent({ name, age }) {
console.log('OptimizedComponent がレンダリングされました');
return (
<div>
<h3>ユーザー情報</h3>
<p>名前: {name}</p>
<p>年齢: {age}</p>
</div>
);
});
このOptimizedComponent
は、name
とage
が前回と同じなら、再レンダリングされません。
実際に使ってみましょう。
function App() {
const [count, setCount] = useState(0);
const [user] = useState({ name: '太郎', age: 25 });
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
カウント増加
</button>
{/* countが変更されても、userが同じなら再レンダリングされない */}
<OptimizedComponent name={user.name} age={user.age} />
</div>
);
}
この例では、count
ボタンを押してもOptimizedComponent
は再レンダリングされません。
なぜなら、name
とage
の値が変わってないからです。
もっと細かく比較をカスタマイズしたい場合
React.memoには、第2引数として比較関数を渡すこともできます。
const UserCard = memo(function UserCard({ user, theme }) {
console.log('UserCard がレンダリングされました');
return (
<div className={`user-card ${theme}`}>
<h3>{user.name}</h3>
<p>メール: {user.email}</p>
<p>最終ログイン: {user.lastLogin}</p>
</div>
);
}, (prevProps, nextProps) => {
// カスタム比較関数
// true を返すと再レンダリングをスキップ
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.user.email === nextProps.user.email &&
prevProps.theme === nextProps.theme
);
});
この比較関数では、lastLogin
の変更は無視して、重要な部分だけをチェックしています。
つまり、最終ログイン時刻が更新されても、名前やメールが同じなら再レンダリングしないということです。
実際に使ってみるとこんな感じになります。
function UserList() {
const [users, setUsers] = useState([
{ id: 1, name: '太郎', email: 'taro@example.com', lastLogin: '2024-01-01' },
{ id: 2, name: '花子', email: 'hanako@example.com', lastLogin: '2024-01-02' }
]);
const [theme, setTheme] = useState('light');
const updateLastLogin = (userId) => {
setUsers(users.map(user =>
user.id === userId
? { ...user, lastLogin: new Date().toISOString().split('T')[0] }
: user
));
};
return (
<div>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
テーマ切り替え
</button>
{users.map(user => (
<div key={user.id}>
<UserCard user={user} theme={theme} />
<button onClick={() => updateLastLogin(user.id)}>
ログイン時刻更新
</button>
</div>
))}
</div>
);
}
「ログイン時刻更新」ボタンを押しても、カスタム比較関数のおかげでUserCard
は再レンダリングされません。
React.memoを使うときの注意点
React.memoは便利ですが、使い方を間違えると効果がなくなってしまいます。
特に注意したいのは、オブジェクトや関数を直接propsで渡すときです。
// 問題のある例:オブジェクトや関数を直接渡している
function ProblematicParent() {
const [count, setCount] = useState(0);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>増加</button>
{/* 毎回新しいオブジェクトが作成されるため、memoが効かない */}
<MemoizedChild
config={{ theme: 'dark', lang: 'ja' }}
onAction={() => console.log('アクション')}
/>
</div>
);
}
const MemoizedChild = memo(function Child({ config, onAction }) {
console.log('MemoizedChild がレンダリングされました(最適化されていない)');
return (
<div>
<p>テーマ: {config.theme}</p>
<p>言語: {config.lang}</p>
<button onClick={onAction}>アクション</button>
</div>
);
});
この例では、{ theme: 'dark', lang: 'ja' }
や() => console.log('アクション')
が毎回新しく作られます。
Reactは「前回と違うオブジェクト・関数だ!」と判断して、結局再レンダリングしてしまうんです。
この問題は、次のセクションで紹介するuseMemo
とuseCallback
で解決できますよ。
useMemoで重い計算をキャッシュしよう
useMemoを使うと、重い計算の結果を覚えておいて、同じ条件なら再計算をスキップできます。
計算量の多い処理がある場合は、useMemoが大活躍します。
useMemoの基本的な使い方
まずは、重い計算処理をuseMemoでキャッシュする例を見てみましょう。
import React, { useState, useMemo } from 'react';
function ExpensiveCalculation({ numbers }) {
console.log('ExpensiveCalculation がレンダリングされました');
// 重い計算処理をuseMemoでキャッシュ
const sum = useMemo(() => {
console.log('重い計算を実行中...');
return numbers.reduce((total, num) => {
// 意図的に重い処理をシミュレート
for (let i = 0; i < 1000000; i++) {
total += num;
}
return total;
}, 0);
}, [numbers]); // numbers が変更された時のみ再計算
const average = useMemo(() => {
console.log('平均値を計算中...');
return sum / numbers.length;
}, [sum, numbers.length]);
return (
<div>
<h3>計算結果</h3>
<p>合計: {sum}</p>
<p>平均: {average.toFixed(2)}</p>
</div>
);
}
上のコードでは、sum
とaverage
をuseMemoでキャッシュしています。
numbers
配列が変わらない限り、重い計算は実行されません。
実際に使ってみましょう。
function App() {
const [numbers] = useState([1, 2, 3, 4, 5]);
const [count, setCount] = useState(0);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
カウント増加
</button>
{/* countが変更されても、numbersが同じなら計算は実行されない */}
<ExpensiveCalculation numbers={numbers} />
</div>
);
}
この例では、count
ボタンを何回押しても「重い計算を実行中...」のメッセージは最初の1回しか表示されません。
useMemoのおかげで、同じnumbers
に対する計算結果が再利用されているからです。
フィルタリングやソートの最適化
検索やソート機能でも、useMemoは威力を発揮します。
function ProductList({ products, searchTerm, sortBy }) {
console.log('ProductList がレンダリングされました');
// 検索結果のキャッシュ
const filteredProducts = useMemo(() => {
console.log('フィルタリング実行中...');
return products.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.description.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [products, searchTerm]);
// ソート結果のキャッシュ
const sortedProducts = useMemo(() => {
console.log('ソート実行中...');
return [...filteredProducts].sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'price':
return a.price - b.price;
case 'rating':
return b.rating - a.rating;
default:
return 0;
}
});
}, [filteredProducts, sortBy]);
return (
<div>
<p>検索結果: {sortedProducts.length}件</p>
<div>
{sortedProducts.map(product => (
<ProductItem key={product.id} product={product} />
))}
</div>
</div>
);
}
const ProductItem = memo(function ProductItem({ product }) {
return (
<div className="product-item">
<h3>{product.name}</h3>
<p>{product.description}</p>
<p>価格: ¥{product.price.toLocaleString()}</p>
<p>評価: {product.rating}/5</p>
</div>
);
});
この例では、フィルタリングとソートを段階的にキャッシュしています。
searchTerm
が変わったときだけフィルタリングが実行され、sortBy
が変わったときだけソートが実行されます。
両方とも変わらなければ、前回の結果がそのまま使われるということです。
オブジェクトや配列の参照を安定化する
React.memoと組み合わせるときに特に重要なのが、オブジェクトや配列の参照を安定化することです。
function ConfigurableComponent({ theme, language }) {
const [count, setCount] = useState(0);
// オブジェクトの参照を安定化
const config = useMemo(() => ({
theme,
language,
apiEndpoint: 'https://api.example.com',
retryCount: 3
}), [theme, language]);
// 配列の参照を安定化
const menuItems = useMemo(() => [
{ id: 1, label: 'ホーム', path: '/' },
{ id: 2, label: '商品', path: '/products' },
{ id: 3, label: 'お問い合わせ', path: '/contact' }
], []); // 依存関係がないので、初回のみ作成
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>増加</button>
<ConfigDisplay config={config} />
<MenuComponent items={menuItems} />
</div>
);
}
const ConfigDisplay = memo(function ConfigDisplay({ config }) {
console.log('ConfigDisplay がレンダリングされました');
return (
<div>
<p>テーマ: {config.theme}</p>
<p>言語: {config.language}</p>
</div>
);
});
const MenuComponent = memo(function MenuComponent({ items }) {
console.log('MenuComponent がレンダリングされました');
return (
<nav>
{items.map(item => (
<a key={item.id} href={item.path}>
{item.label}
</a>
))}
</nav>
);
});
この例では、config
オブジェクトとmenuItems
配列をuseMemoで安定化しています。
count
が変わっても、theme
とlanguage
が同じならconfig
は同じオブジェクトのままです。
そのおかげで、ConfigDisplay
は再レンダリングされません。
menuItems
は依存関係が空配列[]
なので、初回の1回だけ作成されて、その後はずっと同じ配列が使われます。
useCallbackで関数の参照を安定化しよう
useCallbackを使うと、関数の参照を安定化して、不要な再レンダリングを防ぐことができます。
特に、イベントハンドラーを子コンポーネントに渡すときに威力を発揮します。
useCallbackの基本的な使い方
まずは、Todoアプリの例で基本的な使い方を見てみましょう。
import React, { useState, useCallback, memo } from 'react';
function TodoApp() {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState('');
// 関数の参照を安定化
const addTodo = useCallback(() => {
if (newTodo.trim()) {
setTodos(prev => [...prev, {
id: Date.now(),
text: newTodo,
completed: false
}]);
setNewTodo('');
}
}, [newTodo]); // newTodo が変更された時のみ関数を再作成
const toggleTodo = useCallback((id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}, []); // 依存関係がないので、初回のみ作成
const deleteTodo = useCallback((id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
return (
<div>
<h1>Todo アプリ</h1>
<div>
<input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="新しいTodoを入力"
/>
<button onClick={addTodo}>追加</button>
</div>
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
);
}
ここでは、addTodo
、toggleTodo
、deleteTodo
をuseCallbackで安定化しています。
toggleTodo
とdeleteTodo
は依存関係がないので、一度作られたら変わりません。
addTodo
はnewTodo
に依存しているので、入力内容が変わったときだけ新しい関数が作られます。
子コンポーネント側も見てみましょう。
const TodoList = memo(function TodoList({ todos, onToggle, onDelete }) {
console.log('TodoList がレンダリングされました');
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
});
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
console.log(`TodoItem ${todo.id} がレンダリングされました`);
return (
<li style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
<span>{todo.text}</span>
<button onClick={() => onToggle(todo.id)}>
{todo.completed ? '未完了' : '完了'}
</button>
<button onClick={() => onDelete(todo.id)}>削除</button>
</li>
);
});
useCallbackのおかげで、onToggle
とonDelete
の参照が安定しています。
そのため、他のTodoを操作しても、関係ないTodoItemは再レンダリングされません。
フォームのイベントハンドラーを最適化する
複雑なフォームでも、useCallbackが活躍します。
function UserForm({ onSubmit }) {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [errors, setErrors] = useState({});
// フィールド変更ハンドラーの最適化
const handleFieldChange = useCallback((field) => (e) => {
setFormData(prev => ({
...prev,
[field]: e.target.value
}));
// エラーをクリア
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ''
}));
}
}, [errors]); // errors が変更された時のみ再作成
// バリデーション関数の最適化
const validateForm = useCallback(() => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = '名前は必須です';
}
if (!formData.email.trim()) {
newErrors.email = 'メールアドレスは必須です';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = '有効なメールアドレスを入力してください';
}
if (!formData.message.trim()) {
newErrors.message = 'メッセージは必須です';
}
return newErrors;
}, [formData]);
// 送信ハンドラーの最適化
const handleSubmit = useCallback((e) => {
e.preventDefault();
const validationErrors = validateForm();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
onSubmit(formData);
}, [formData, validateForm, onSubmit]);
// リセットハンドラーの最適化
const handleReset = useCallback(() => {
setFormData({ name: '', email: '', message: '' });
setErrors({});
}, []);
return (
<form onSubmit={handleSubmit}>
<FormField
label="名前"
value={formData.name}
onChange={handleFieldChange('name')}
error={errors.name}
/>
<FormField
label="メールアドレス"
type="email"
value={formData.email}
onChange={handleFieldChange('email')}
error={errors.email}
/>
<FormField
label="メッセージ"
type="textarea"
value={formData.message}
onChange={handleFieldChange('message')}
error={errors.message}
/>
<div>
<button type="submit">送信</button>
<button type="button" onClick={handleReset}>リセット</button>
</div>
</form>
);
}
この例では、複数のイベントハンドラーをそれぞれ適切な依存関係でuseCallbackしています。
handleFieldChange
は高階関数(関数を返す関数)になっているので、各フィールド用の個別ハンドラーを効率的に作れます。
フォームフィールドコンポーネントも最適化してみましょう。
const FormField = memo(function FormField({ label, type = 'text', value, onChange, error }) {
console.log(`FormField ${label} がレンダリングされました`);
return (
<div>
<label>{label}</label>
{type === 'textarea' ? (
<textarea value={value} onChange={onChange} />
) : (
<input type={type} value={value} onChange={onChange} />
)}
{error && <span className="error">{error}</span>}
</div>
);
});
useCallbackとReact.memoの組み合わせで、関係ないフィールドが再レンダリングされることを防げます。
複雑なイベントハンドラーも最適化できる
データテーブルのような複雑なコンポーネントでも、useCallbackは有効です。
function DataTable({ data, onSort, onFilter, onSelect }) {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [selectedRows, setSelectedRows] = useState(new Set());
// ソートハンドラーの最適化
const handleSort = useCallback((key) => {
setSortConfig(prev => {
const direction = prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc';
const newConfig = { key, direction };
onSort(newConfig);
return newConfig;
});
}, [onSort]);
// 行選択ハンドラーの最適化
const handleRowSelect = useCallback((rowId) => {
setSelectedRows(prev => {
const newSelected = new Set(prev);
if (newSelected.has(rowId)) {
newSelected.delete(rowId);
} else {
newSelected.add(rowId);
}
onSelect(Array.from(newSelected));
return newSelected;
});
}, [onSelect]);
// 全選択ハンドラーの最適化
const handleSelectAll = useCallback(() => {
const allSelected = selectedRows.size === data.length;
const newSelected = allSelected ? new Set() : new Set(data.map(row => row.id));
setSelectedRows(newSelected);
onSelect(Array.from(newSelected));
}, [data, selectedRows.size, onSelect]);
return (
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
checked={selectedRows.size === data.length && data.length > 0}
onChange={handleSelectAll}
/>
</th>
<TableHeader
label="名前"
sortKey="name"
sortConfig={sortConfig}
onSort={handleSort}
/>
<TableHeader
label="メール"
sortKey="email"
sortConfig={sortConfig}
onSort={handleSort}
/>
<TableHeader
label="役割"
sortKey="role"
sortConfig={sortConfig}
onSort={handleSort}
/>
</tr>
</thead>
<tbody>
{data.map(row => (
<TableRow
key={row.id}
row={row}
isSelected={selectedRows.has(row.id)}
onSelect={handleRowSelect}
/>
))}
</tbody>
</table>
);
}
このように、複雑な状態管理を含むコンポーネントでも、useCallbackを使って各ハンドラーを適切に最適化できます。
子コンポーネントも最適化しておけば、大量のデータがあってもスムーズに動作するテーブルが作れますよ。
実際のプロジェクトで使える最適化パターン
ここまでの知識を組み合わせて、実際のプロジェクトでよく使われる最適化パターンを見ていきましょう。
商品リストコンポーネントの完全最適化
ECサイトでよくある商品リストを、パフォーマンスを意識して作ってみます。
function OptimizedProductList({ products, category, priceRange, onAddToCart }) {
const [sortBy, setSortBy] = useState('name');
const [searchTerm, setSearchTerm] = useState('');
// フィルタリングの最適化
const filteredProducts = useMemo(() => {
return products.filter(product => {
const matchesCategory = !category || product.category === category;
const matchesPrice = product.price >= priceRange.min && product.price <= priceRange.max;
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase());
return matchesCategory && matchesPrice && matchesSearch;
});
}, [products, category, priceRange, searchTerm]);
// ソートの最適化
const sortedProducts = useMemo(() => {
return [...filteredProducts].sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'price':
return a.price - b.price;
case 'rating':
return b.rating - a.rating;
default:
return 0;
}
});
}, [filteredProducts, sortBy]);
// カート追加ハンドラーの最適化
const handleAddToCart = useCallback((productId) => {
onAddToCart(productId);
}, [onAddToCart]);
// 検索ハンドラーの最適化
const handleSearchChange = useCallback((e) => {
setSearchTerm(e.target.value);
}, []);
// ソートハンドラーの最適化
const handleSortChange = useCallback((e) => {
setSortBy(e.target.value);
}, []);
return (
<div>
<div className="controls">
<input
type="text"
placeholder="商品を検索..."
value={searchTerm}
onChange={handleSearchChange}
/>
<select value={sortBy} onChange={handleSortChange}>
<option value="name">名前順</option>
<option value="price">価格順</option>
<option value="rating">評価順</option>
</select>
</div>
<div className="product-grid">
{sortedProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
<div className="summary">
{filteredProducts.length}件の商品が見つかりました
</div>
</div>
);
}
この実装では、フィルタリングとソートを段階的にuseMemoでキャッシュしています。
searchTerm
が変わったときはフィルタリングから再実行されますが、sortBy
だけが変わったときはソートのみが実行されます。
商品カードコンポーネントも最適化しましょう。
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
const handleAddToCart = useCallback(() => {
onAddToCart(product.id);
}, [onAddToCart, product.id]);
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<p className="price">¥{product.price.toLocaleString()}</p>
<div className="rating">
評価: {product.rating}/5 ({product.reviewCount}件)
</div>
<button onClick={handleAddToCart}>
カートに追加
</button>
</div>
);
});
React.memoとuseCallbackの組み合わせで、関係ない商品カードが再レンダリングされることを防いでいます。
フォームコンポーネントの完全最適化
複雑なお問い合わせフォームも、パフォーマンスを意識して作ってみましょう。
function OptimizedContactForm({ onSubmit }) {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
subject: '',
message: '',
category: 'general'
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// フィールド更新の最適化
const handleFieldChange = useCallback((field) => {
return (e) => {
const value = e.target.value;
setFormData(prev => ({
...prev,
[field]: value
}));
// リアルタイムバリデーション
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ''
}));
}
};
}, [errors]);
// バリデーション関数の最適化
const validation = useMemo(() => ({
name: (value) => {
if (!value.trim()) return '名前は必須です';
if (value.length < 2) return '名前は2文字以上で入力してください';
return '';
},
email: (value) => {
if (!value.trim()) return 'メールアドレスは必須です';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return '有効なメールアドレスを入力してください';
}
return '';
},
phone: (value) => {
if (value && !/^[0-9-]+$/.test(value)) {
return '電話番号は数字とハイフンのみ入力してください';
}
return '';
},
subject: (value) => {
if (!value.trim()) return '件名は必須です';
return '';
},
message: (value) => {
if (!value.trim()) return 'メッセージは必須です';
if (value.length < 10) return 'メッセージは10文字以上で入力してください';
return '';
}
}), []);
// フォーム送信の最適化
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
// バリデーション実行
const newErrors = {};
Object.keys(formData).forEach(field => {
if (validation[field]) {
const error = validation[field](formData[field]);
if (error) newErrors[field] = error;
}
});
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
setFormData({
name: '',
email: '',
phone: '',
subject: '',
message: '',
category: 'general'
});
setErrors({});
} catch (error) {
setErrors({ submit: error.message });
} finally {
setIsSubmitting(false);
}
}, [formData, validation, onSubmit]);
// フィールドコンポーネント用のpropsを最適化
const fieldProps = useMemo(() => ({
name: {
label: '名前',
type: 'text',
required: true,
value: formData.name,
onChange: handleFieldChange('name'),
error: errors.name
},
email: {
label: 'メールアドレス',
type: 'email',
required: true,
value: formData.email,
onChange: handleFieldChange('email'),
error: errors.email
},
phone: {
label: '電話番号',
type: 'tel',
value: formData.phone,
onChange: handleFieldChange('phone'),
error: errors.phone
},
subject: {
label: '件名',
type: 'text',
required: true,
value: formData.subject,
onChange: handleFieldChange('subject'),
error: errors.subject
},
message: {
label: 'メッセージ',
type: 'textarea',
required: true,
value: formData.message,
onChange: handleFieldChange('message'),
error: errors.message
}
}), [formData, handleFieldChange, errors]);
return (
<form onSubmit={handleSubmit}>
<OptimizedFormField {...fieldProps.name} />
<OptimizedFormField {...fieldProps.email} />
<OptimizedFormField {...fieldProps.phone} />
<div>
<label>カテゴリ</label>
<select
value={formData.category}
onChange={handleFieldChange('category')}
>
<option value="general">一般的なお問い合わせ</option>
<option value="support">サポート</option>
<option value="sales">営業</option>
<option value="other">その他</option>
</select>
</div>
<OptimizedFormField {...fieldProps.subject} />
<OptimizedFormField {...fieldProps.message} />
{errors.submit && (
<div className="error-message">
{errors.submit}
</div>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中...' : '送信'}
</button>
</form>
);
}
const OptimizedFormField = memo(function FormField({
label,
type,
required,
value,
onChange,
error
}) {
return (
<div className="form-field">
<label>
{label}
{required && <span className="required">*</span>}
</label>
{type === 'textarea' ? (
<textarea
value={value}
onChange={onChange}
rows={4}
/>
) : (
<input
type={type}
value={value}
onChange={onChange}
/>
)}
{error && (
<span className="field-error">{error}</span>
)}
</div>
);
});
この実装では、バリデーション関数をuseMemoでキャッシュし、フィールドのpropsもuseMemoで最適化しています。
各フィールドコンポーネントはReact.memoで包まれているので、関係ないフィールドが再レンダリングされることはありません。
データ取得コンポーネントの最適化
APIからデータを取得するコンポーネントも、適切に最適化できます。
function OptimizedUserDashboard({ userId }) {
const [userData, setUserData] = useState(null);
const [userPosts, setUserPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// データ取得の最適化
const fetchUserData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [userResponse, postsResponse] = await Promise.all([
fetch(`/api/users/${userId}`),
fetch(`/api/users/${userId}/posts`)
]);
if (!userResponse.ok || !postsResponse.ok) {
throw new Error('データの取得に失敗しました');
}
const [user, posts] = await Promise.all([
userResponse.json(),
postsResponse.json()
]);
setUserData(user);
setUserPosts(posts);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [userId]);
// userIdが変更された時のみデータを再取得
useEffect(() => {
fetchUserData();
}, [fetchUserData]);
// ユーザー更新ハンドラーの最適化
const handleUserUpdate = useCallback(async (updatedData) => {
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedData)
});
if (!response.ok) {
throw new Error('更新に失敗しました');
}
const updatedUser = await response.json();
setUserData(updatedUser);
} catch (err) {
setError(err.message);
}
}, [userId]);
// 投稿削除ハンドラーの最適化
const handlePostDelete = useCallback(async (postId) => {
try {
const response = await fetch(`/api/posts/${postId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('削除に失敗しました');
}
setUserPosts(prev => prev.filter(post => post.id !== postId));
} catch (err) {
setError(err.message);
}
}, []);
// 統計データの計算を最適化
const userStats = useMemo(() => {
if (!userPosts.length) return null;
return {
totalPosts: userPosts.length,
publishedPosts: userPosts.filter(post => post.published).length,
averageViews: userPosts.reduce((sum, post) => sum + post.views, 0) / userPosts.length,
lastPostDate: new Date(Math.max(...userPosts.map(post => new Date(post.createdAt))))
};
}, [userPosts]);
if (loading) {
return <LoadingSpinner />;
}
if (error) {
return (
<ErrorMessage
message={error}
onRetry={fetchUserData}
/>
);
}
if (!userData) {
return <div>ユーザーが見つかりません</div>;
}
return (
<div className="user-dashboard">
<UserProfile
user={userData}
onUpdate={handleUserUpdate}
/>
{userStats && (
<UserStats stats={userStats} />
)}
<UserPostList
posts={userPosts}
onDelete={handlePostDelete}
/>
</div>
);
}
このように、実際のプロジェクトでも、React.memo、useMemo、useCallbackを組み合わせることで、大幅なパフォーマンス向上が期待できます。
重要なのは、必要な場所に適切に最適化を適用することです。
パフォーマンスを測定してデバッグしよう
最適化の効果を確認するには、実際にパフォーマンスを測定することが大切です。
「なんとなく速くなった気がする」ではなく、数値で効果を確認しましょう。
React DevToolsのProfilerを使う
React DevToolsには、パフォーマンスを測定できるProfilerという機能があります。
// Profiler APIを使用したパフォーマンス測定
import React, { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration, baseDuration, startTime, commitTime) {
console.log('Profiler:', {
id, // プロファイラーのID
phase, // "mount" または "update"
actualDuration, // このレンダリングにかかった時間
baseDuration, // 最適化なしでかかる推定時間
startTime, // レンダリング開始時刻
commitTime // コミット時刻
});
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<UserDashboard />
<ProductList />
</Profiler>
);
}
// 個別コンポーネントの測定
function ProfiledComponent({ data }) {
return (
<Profiler id="ExpensiveComponent" onRender={onRenderCallback}>
<ExpensiveComponent data={data} />
</Profiler>
);
}
Profiler
コンポーネントで包むだけで、そのコンポーネントのレンダリング時間を測定できます。
actualDuration
が実際にかかった時間で、baseDuration
が最適化なしでかかると予想される時間です。
カスタムフックでパフォーマンスを監視する
自分でパフォーマンス測定用のフックを作ることもできます。
// パフォーマンス測定用カスタムフック
function usePerformanceMonitor(componentName) {
const renderCountRef = useRef(0);
const lastRenderTime = useRef(Date.now());
useEffect(() => {
renderCountRef.current += 1;
const currentTime = Date.now();
const timeSinceLastRender = currentTime - lastRenderTime.current;
console.log(`${componentName} - レンダリング回数: ${renderCountRef.current}, 前回からの時間: ${timeSinceLastRender}ms`);
lastRenderTime.current = currentTime;
});
return {
renderCount: renderCountRef.current,
logRenderInfo: (additionalInfo) => {
console.log(`${componentName} 追加情報:`, additionalInfo);
}
};
}
// 使用例
function MonitoredComponent({ data }) {
const { renderCount, logRenderInfo } = usePerformanceMonitor('MonitoredComponent');
useEffect(() => {
logRenderInfo({ dataLength: data.length });
}, [data, logRenderInfo]);
return (
<div>
<p>レンダリング回数: {renderCount}</p>
<p>データ件数: {data.length}</p>
</div>
);
}
このフックを使うと、コンポーネントが何回レンダリングされたか、前回のレンダリングからどのくらい時間が経ったかが分かります。
メモリ使用量を監視する
ブラウザのメモリ使用量も監視できます。
// メモリ使用量監視フック
function useMemoryMonitor() {
const [memoryInfo, setMemoryInfo] = useState(null);
useEffect(() => {
const updateMemoryInfo = () => {
if ('memory' in performance) {
setMemoryInfo({
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
});
}
};
updateMemoryInfo();
const interval = setInterval(updateMemoryInfo, 5000);
return () => clearInterval(interval);
}, []);
return memoryInfo;
}
// デバッグ用コンポーネント
function MemoryMonitor() {
const memoryInfo = useMemoryMonitor();
if (!memoryInfo) {
return <div>メモリ情報を取得できません</div>;
}
const formatBytes = (bytes) => {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
return (
<div style={{
position: 'fixed',
top: 10,
right: 10,
background: 'rgba(0,0,0,0.8)',
color: 'white',
padding: '10px',
fontSize: '12px'
}}>
<div>使用中: {formatBytes(memoryInfo.usedJSHeapSize)}</div>
<div>合計: {formatBytes(memoryInfo.totalJSHeapSize)}</div>
<div>上限: {formatBytes(memoryInfo.jsHeapSizeLimit)}</div>
</div>
);
}
開発中にこのコンポーネントを表示しておくと、メモリ使用量の変化をリアルタイムで確認できます。
レンダリング回数を可視化する
どのコンポーネントが何回レンダリングされているかを可視化するフックも作れます。
// レンダリング回数を可視化するフック
function useRenderTracker(componentName, props = {}) {
const renderCount = useRef(0);
const propsHistory = useRef([]);
renderCount.current += 1;
propsHistory.current.push({
renderCount: renderCount.current,
timestamp: Date.now(),
props: { ...props }
});
// 履歴は最新の10件のみ保持
if (propsHistory.current.length > 10) {
propsHistory.current = propsHistory.current.slice(-10);
}
useEffect(() => {
console.group(`🔄 ${componentName} - レンダリング #${renderCount.current}`);
console.log('Props:', props);
console.log('履歴:', propsHistory.current);
console.groupEnd();
});
return {
renderCount: renderCount.current,
renderHistory: propsHistory.current
};
}
// 使用例
function TrackedComponent({ user, posts, filters }) {
const { renderCount } = useRenderTracker('TrackedComponent', {
userId: user?.id,
postsLength: posts?.length,
filters
});
return (
<div>
<span style={{ fontSize: '10px', color: 'gray' }}>
レンダリング: {renderCount}回
</span>
<UserProfile user={user} />
<PostList posts={posts} filters={filters} />
</div>
);
}
コンソールを見ると、どのpropsが変わったときにレンダリングされているかが詳しく分かります。
これらの測定ツールを使って、最適化の効果を数値で確認してみてくださいね。
よくある最適化の間違いと対策
最適化を行う際に、ついついやってしまいがちな間違いがあります。
これらの間違いを避けることで、より効果的な最適化ができますよ。
間違い1:何でもかんでもmemoを使っちゃう
「最適化は良いことだから、全部memoにしちゃえ!」と思ってしまうことがありますが、これは逆効果になることがあります。
// 間違い: 不要な場所でmemoを使用
const SimpleComponent = memo(function SimpleComponent({ text }) {
return <span>{text}</span>;
});
// このようなシンプルなコンポーネントでmemoを使う必要はない
// memo自体にもオーバーヘッドがある
こんなにシンプルなコンポーネントなら、memoのオーバーヘッドの方が大きくなってしまいます。
React.memoは、複雑な処理や重いレンダリングがあるコンポーネントにだけ使いましょう。
// 正しい: 複雑な処理や重いコンポーネントのみmemoを使用
const ComplexComponent = memo(function ComplexComponent({ data, onAction }) {
// 複雑な処理やレンダリング
const processedData = data.map(item => ({
...item,
calculated: heavyCalculation(item)
}));
return (
<div>
{processedData.map(item => (
<ExpensiveItem key={item.id} item={item} onAction={onAction} />
))}
</div>
);
});
間違い2:依存関係の設定を間違える
useCallbackやuseMemoの依存関係を間違えると、バグの原因になります。
// 間違い: 依存関係が不足している
function ProblematicComponent({ apiUrl, userId }) {
const [data, setData] = useState(null);
const fetchData = useCallback(async () => {
const response = await fetch(`${apiUrl}/users/${userId}`);
const result = await response.json();
setData(result);
}, []); // 依存関係が不足!apiUrlとuserIdが変更されても古い値を使用
useEffect(() => {
fetchData();
}, [fetchData]);
return <div>{data?.name}</div>;
}
上の例では、apiUrl
やuserId
が変わっても、古い値でAPIを呼び出してしまいます。
// 正しい: すべての依存関係を含める
function CorrectComponent({ apiUrl, userId }) {
const [data, setData] = useState(null);
const fetchData = useCallback(async () => {
const response = await fetch(`${apiUrl}/users/${userId}`);
const result = await response.json();
setData(result);
}, [apiUrl, userId]); // 正しい依存関係
useEffect(() => {
fetchData();
}, [fetchData]);
return <div>{data?.name}</div>;
}
依存関係は漏れなく設定することが大切です。
間違い3:オブジェクトや配列を毎回新しく作っちゃう
React.memoを使っているのに、propsとして渡すオブジェクトや配列を毎回新しく作ってしまうパターンです。
// 間違い: 毎回新しいオブジェクトを作成
function ProblematicParent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>増加</button>
{/* 毎回新しいオブジェクトが作成されるため、memoが効かない */}
<MemoizedChild
config={{ theme: 'dark', lang: 'ja' }} // 毎回新しいオブジェクト
items={['a', 'b', 'c']} // 毎回新しい配列
/>
</div>
);
}
const MemoizedChild = memo(function Child({ config, items }) {
console.log('Child がレンダリングされました'); // 毎回実行される
return <div>Child Component</div>;
});
これじゃあ、せっかくReact.memoを使っても意味がありません。
// 正しい: useMemoで参照を安定化
function CorrectParent() {
const [count, setCount] = useState(0);
const config = useMemo(() => ({
theme: 'dark',
lang: 'ja'
}), []);
const items = useMemo(() => ['a', 'b', 'c'], []);
return (
<div>
<button onClick={() => setCount(count + 1)}>増加</button>
<MemoizedChild config={config} items={items} />
</div>
);
}
useMemoで参照を安定化することで、React.memoが正しく動作するようになります。
間違い4:useCallbackの依存関係が頻繁に変わっちゃう
useCallbackの依存関係が頻繁に変わると、結局毎回新しい関数が作られてしまいます。
// 間違い: 依存関係が頻繁に変わるuseCallback
function ProblematicCallback({ data }) {
const [filter, setFilter] = useState('');
// dataとfilterが変わるたびに新しい関数が作成される
const handleItemClick = useCallback((itemId) => {
const item = data.find(d => d.id === itemId);
if (item.name.includes(filter)) {
console.log('選択されたアイテム:', item);
}
}, [data, filter]); // 依存関係が頻繁に変更される
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<ItemList data={data} onItemClick={handleItemClick} />
</div>
);
}
この場合、filter
が入力のたびに変わるので、useCallbackの効果がありません。
// 正しい: 安定した関数を作成
function CorrectCallback({ data }) {
const [filter, setFilter] = useState('');
// 関数内でstateを参照せず、依存関係を減らす
const handleItemClick = useCallback((itemId) => {
const item = data.find(d => d.id === itemId);
console.log('選択されたアイテム:', item);
}, [data]); // filterに依存しない
const filteredData = useMemo(() => {
return data.filter(item => item.name.includes(filter));
}, [data, filter]);
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<ItemList data={filteredData} onItemClick={handleItemClick} />
</div>
);
}
フィルタリングをuseMemoで行い、ハンドラーの依存関係を減らすことで、より効果的な最適化ができます。
最適化は「やればやるほど良い」ものではありません。 適切な場所に、適切な方法で最適化を行うことが大切ですね。
まとめ
お疲れさまでした! Reactの再レンダリングと最適化について、たくさん学びましたね。
今日学んだ重要なポイント
再レンダリングの理解
- state、props、親コンポーネントの変更で再レンダリングが発生する
- 不要な再レンダリングがパフォーマンス問題の原因になる
- 親が変わると子も一緒に再レンダリングされる
最適化の3つの武器
- React.memo - propsが変わらない場合の再レンダリングを防ぐ
- useMemo - 重い計算結果やオブジェクトの参照を安定化
- useCallback - 関数の参照を安定化してパフォーマンスを向上
実践で気をつけること
- 過度な最適化は避ける(測定してから最適化)
- 依存関係は正確に設定する
- オブジェクトや配列の参照に注意する
- 最適化の効果を測定して確認する
これからReactを使うときに
今回学んだ最適化手法は、すべてのコンポーネントに使う必要はありません。
まずは普通にReactアプリを作って、実際にパフォーマンスの問題が起きたときに適用してみてください。
「なんか重いな」と感じたら、今日学んだ知識を思い出して、適切な最適化を行えばOKです。
最適化は魔法ではありませんが、正しく使えば確実にアプリのパフォーマンスを向上させることができます。
ぜひ実際のプロジェクトで試してみて、サクサク動くReactアプリを作ってみてくださいね!