Reactでリストが更新されない|配列操作の正しい方法

Reactでリストが更新されない問題を解決。配列のstateを正しく操作する方法、不変性の重要性、push・splice・sortの正しい使い方を詳しく解説します。

Learning Next 運営
64 分で読めます

みなさん、Reactでリストを更新しようとしたのに画面に反映されなくて困ったことありませんか?

「配列に要素を追加したのに表示されない」「リストをソートしても順番が変わらない」「配列から要素を削除しても消えない」

そんな経験をしたことがある方も多いでしょう。

この記事では、Reactで配列のstateが更新されない原因と、正しい配列操作の方法について詳しく解説します。

不変性の概念から具体的なコード例まで、実際のエラーパターンとその解決方法を一緒に学んでいきましょう。

Reactでリストが更新されない原因を知ろう

まずは、リストが更新されない根本的な原因を理解しましょう。

原因がわかれば、解決策も見えてきますからね。

よくある間違い:リストが更新されない例

こんなコードを書いて、うまくいかなかった経験ありませんか?

import React, { useState } from 'react';

// ❌ 間違った例:リストが更新されない
function BrokenTodoList() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'React を学習する', completed: false },
        { id: 2, text: 'アプリを作る', completed: false }
    ]);
    const [newTodo, setNewTodo] = useState('');
    
    // ❌ 間違った追加方法
    const addTodo = () => {
        todos.push({
            id: Date.now(),
            text: newTodo,
            completed: false
        });
        setTodos(todos); // 配列の参照が同じなので更新されない
        setNewTodo('');
    };
    
    // ❌ 間違った削除方法
    const deleteTodo = (id) => {
        const index = todos.findIndex(todo => todo.id === id);
        todos.splice(index, 1); // 元の配列を直接変更
        setTodos(todos); // 参照が同じなので更新されない
    };
    
    // ❌ 間違ったソート方法
    const sortTodos = () => {
        todos.sort((a, b) => a.text.localeCompare(b.text)); // 元の配列を直接変更
        setTodos(todos); // 参照が同じなので更新されない
    };
    
    return (
        <div>
            <h1>壊れたTodoリスト(更新されません)</h1>
            <input
                value={newTodo}
                onChange={(e) => setNewTodo(e.target.value)}
                placeholder="新しいTodo"
            />
            <button onClick={addTodo}>追加</button>
            <button onClick={sortTodos}>ソート</button>
            
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>
                        {todo.text}
                        <button onClick={() => deleteTodo(todo.id)}>削除</button>
                    </li>
                ))}
            </ul>
        </div>
    );
}

このコードを実行してみると、ボタンを押しても何も起こりません。

「なんで?ちゃんとコード書いたのに...」って思いますよね。 実は、これにはReactの重要な仕組みが関係しているんです。

なぜ更新されないのか?その理由

問題の核心は、配列の参照が変わっていないことにあります。

// Reactの比較方法
const oldTodos = [{ id: 1, text: 'A' }, { id: 2, text: 'B' }];
const newTodos = oldTodos;

// push や splice を使った場合
oldTodos.push({ id: 3, text: 'C' });

console.log(oldTodos === newTodos); // true(同じ参照)
// React は参照が同じなので「変更なし」と判断

Reactは配列やオブジェクトの中身ではなく、参照を比較します。

pushspliceを使うと、元の配列を直接変更するため、参照は同じまま。 そのため、Reactは「変更がない」と判断して再レンダリングしないんです。

Reactの更新判定メカニズム

もう少し詳しく見てみましょう。

// React の内部的な比較処理(簡略版)
function isStateChanged(oldState, newState) {
    // オブジェクトや配列は参照比較
    return oldState !== newState;
}

// 問題のあるパターン
const oldArray = [1, 2, 3];
const newArray = oldArray;
newArray.push(4);

console.log(isStateChanged(oldArray, newArray)); // false
// React は「変更なし」と判断して再レンダリングしない

// 正しいパターン
const oldArray2 = [1, 2, 3];
const newArray2 = [...oldArray2, 4]; // 新しい配列を作成

console.log(isStateChanged(oldArray2, newArray2)); // true
// React は「変更あり」と判断して再レンダリング

スプレッド演算子(...)を使うと、新しい配列が作成されます。

これで参照が変わるため、Reactは変更を検知して画面を更新してくれるんです。

不変性(Immutability)とは何か

不変性とは、既存のデータを変更せず、新しいデータを作成することです。

// 不変性の概念
const originalArray = [1, 2, 3];

// ❌ 可変操作(元の配列を変更)
originalArray.push(4);
console.log(originalArray); // [1, 2, 3, 4] - 元の配列が変更される

// ✅ 不変操作(新しい配列を作成)
const originalArray2 = [1, 2, 3];
const newArray = [...originalArray2, 4];
console.log(originalArray2); // [1, 2, 3] - 元の配列は変更されない
console.log(newArray);       // [1, 2, 3, 4] - 新しい配列が作成される

元の配列は触らずに、新しい配列を作る。

これが不変性の基本的な考え方です。

不変性が重要な理由

不変性を守ることで、以下のメリットがあります。

  1. React の変更検知システムに対応
  2. 副作用の防止
  3. デバッグの容易さ
  4. タイムトラベルデバッグの実現
  5. パフォーマンス最適化の基盤

つまり、不変性を意識することで、Reactがちゃんと動くようになるんです。

正しい配列操作の方法をマスターしよう

配列のstateを正しく更新する方法を操作別に詳しく説明します。

一つずつ覚えていけば、確実にできるようになりますよ。

要素の追加(pushの代替方法)

pushの代わりに、以下の方法を使いましょう。

import React, { useState } from 'react';

function TodoList() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'React を学習する', completed: false },
        { id: 2, text: 'アプリを作る', completed: false }
    ]);
    const [newTodo, setNewTodo] = useState('');
    
    // ✅ 正しい追加方法1: スプレッド演算子
    const addTodoSpread = () => {
        setTodos([
            ...todos,
            {
                id: Date.now(),
                text: newTodo,
                completed: false
            }
        ]);
        setNewTodo('');
    };
    
    // ✅ 正しい追加方法2: concat メソッド
    const addTodoConcat = () => {
        const newTodoItem = {
            id: Date.now(),
            text: newTodo,
            completed: false
        };
        setTodos(todos.concat(newTodoItem));
        setNewTodo('');
    };
    
    // ✅ 正しい追加方法3: 関数型更新
    const addTodoFunction = () => {
        setTodos(prevTodos => [
            ...prevTodos,
            {
                id: Date.now(),
                text: newTodo,
                completed: false
            }
        ]);
        setNewTodo('');
    };
    
    // ✅ 特定位置への挿入
    const insertTodoAtPosition = (position) => {
        const newTodoItem = {
            id: Date.now(),
            text: newTodo,
            completed: false
        };
        
        setTodos([
            ...todos.slice(0, position),
            newTodoItem,
            ...todos.slice(position)
        ]);
        setNewTodo('');
    };
    
    // ✅ 先頭に追加
    const addTodoAtBeginning = () => {
        setTodos([
            {
                id: Date.now(),
                text: newTodo,
                completed: false
            },
            ...todos
        ]);
        setNewTodo('');
    };
    
    return (
        <div>
            <h1>正しいTodoリスト</h1>
            <input
                value={newTodo}
                onChange={(e) => setNewTodo(e.target.value)}
                placeholder="新しいTodo"
            />
            <div>
                <button onClick={addTodoSpread}>末尾に追加</button>
                <button onClick={addTodoAtBeginning}>先頭に追加</button>
                <button onClick={() => insertTodoAtPosition(1)}>
                    2番目に挿入
                </button>
            </div>
            
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>
                        {todo.text}
                    </li>
                ))}
            </ul>
        </div>
    );
}

スプレッド演算子(...todos)が一番よく使われる方法です。

concatメソッドも元の配列を変更しないので安全ですね。 関数型更新(prevTodos => ...)を使うと、最新の状態を確実に取得できます。

要素の削除(spliceの代替方法)

spliceの代わりに、以下の方法を使いましょう。

function TodoListWithDelete() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'React を学習する', completed: false },
        { id: 2, text: 'アプリを作る', completed: false },
        { id: 3, text: 'テストを書く', completed: false }
    ]);
    
    // ✅ 正しい削除方法1: filter メソッド
    const deleteTodoFilter = (id) => {
        setTodos(todos.filter(todo => todo.id !== id));
    };
    
    // ✅ 正しい削除方法2: スプレッド演算子とslice
    const deleteTodoSlice = (id) => {
        const index = todos.findIndex(todo => todo.id === id);
        if (index !== -1) {
            setTodos([
                ...todos.slice(0, index),
                ...todos.slice(index + 1)
            ]);
        }
    };
    
    // ✅ 複数削除
    const deleteMultipleTodos = (idsToDelete) => {
        setTodos(todos.filter(todo => !idsToDelete.includes(todo.id)));
    };
    
    // ✅ 条件による削除
    const deleteCompletedTodos = () => {
        setTodos(todos.filter(todo => !todo.completed));
    };
    
    // ✅ 先頭要素の削除
    const deleteFirstTodo = () => {
        setTodos(todos.slice(1));
    };
    
    // ✅ 末尾要素の削除
    const deleteLastTodo = () => {
        setTodos(todos.slice(0, -1));
    };
    
    return (
        <div>
            <h1>削除機能付きTodoリスト</h1>
            
            <div>
                <button onClick={deleteCompletedTodos}>
                    完了済みを削除
                </button>
                <button onClick={deleteFirstTodo}>
                    先頭を削除
                </button>
                <button onClick={deleteLastTodo}>
                    末尾を削除
                </button>
                <button onClick={() => deleteMultipleTodos([1, 2])}>
                    ID 1,2 を削除
                </button>
            </div>
            
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>
                        <input
                            type="checkbox"
                            checked={todo.completed}
                            onChange={() => toggleTodo(todo.id)}
                        />
                        {todo.text}
                        <button onClick={() => deleteTodoFilter(todo.id)}>
                            削除
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    );
}

filterメソッドが一番簡単で直感的です。

「このIDではない要素だけを残す」という考え方ですね。 複数の条件での削除も、filterを使えば簡単にできます。

要素の更新・変更

配列内の特定の要素を更新する方法です。

function TodoListWithUpdate() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'React を学習する', completed: false, priority: 'high' },
        { id: 2, text: 'アプリを作る', completed: false, priority: 'medium' },
        { id: 3, text: 'テストを書く', completed: false, priority: 'low' }
    ]);
    
    // ✅ 単一プロパティの更新
    const toggleTodo = (id) => {
        setTodos(todos.map(todo =>
            todo.id === id
                ? { ...todo, completed: !todo.completed }
                : todo
        ));
    };
    
    // ✅ 複数プロパティの更新
    const updateTodo = (id, updates) => {
        setTodos(todos.map(todo =>
            todo.id === id
                ? { ...todo, ...updates }
                : todo
        ));
    };
    
    // ✅ テキストの編集
    const editTodoText = (id, newText) => {
        setTodos(todos.map(todo =>
            todo.id === id
                ? { ...todo, text: newText }
                : todo
        ));
    };
    
    // ✅ 優先度の変更
    const changePriority = (id, newPriority) => {
        setTodos(todos.map(todo =>
            todo.id === id
                ? { ...todo, priority: newPriority }
                : todo
        ));
    };
    
    // ✅ 条件による一括更新
    const markAllAsCompleted = () => {
        setTodos(todos.map(todo => ({ ...todo, completed: true })));
    };
    
    // ✅ 関数型更新での複雑な更新
    const updateTodoAdvanced = (id, updater) => {
        setTodos(prevTodos => 
            prevTodos.map(todo =>
                todo.id === id ? updater(todo) : todo
            )
        );
    };
    
    return (
        <div>
            <h1>更新機能付きTodoリスト</h1>
            
            <button onClick={markAllAsCompleted}>
                すべて完了にする
            </button>
            
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>
                        <input
                            type="checkbox"
                            checked={todo.completed}
                            onChange={() => toggleTodo(todo.id)}
                        />
                        
                        <input
                            type="text"
                            value={todo.text}
                            onChange={(e) => editTodoText(todo.id, e.target.value)}
                        />
                        
                        <select
                            value={todo.priority}
                            onChange={(e) => changePriority(todo.id, e.target.value)}
                        >
                            <option value="high">高</option>
                            <option value="medium">中</option>
                            <option value="low">低</option>
                        </select>
                        
                        <button onClick={() => updateTodo(todo.id, {
                            text: todo.text + ' (編集済み)',
                            priority: 'high'
                        })}>
                            一括更新
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    );
}

mapメソッドで配列をループして、条件に合う要素だけを更新します。

スプレッド演算子(...todo)で既存のプロパティをコピーして、新しい値で上書きするのがポイントです。

配列のソート

sortメソッドは元の配列を変更するため、コピーしてからソートします。

function SortableTodoList() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'C React を学習する', completed: false, createdAt: '2024-01-03' },
        { id: 2, text: 'A アプリを作る', completed: true, createdAt: '2024-01-01' },
        { id: 3, text: 'B テストを書く', completed: false, createdAt: '2024-01-02' }
    ]);
    
    // ✅ アルファベット順ソート
    const sortByText = () => {
        setTodos([...todos].sort((a, b) => a.text.localeCompare(b.text)));
    };
    
    // ✅ 日付順ソート
    const sortByDate = () => {
        setTodos([...todos].sort((a, b) => 
            new Date(a.createdAt) - new Date(b.createdAt)
        ));
    };
    
    // ✅ 完了状態でソート
    const sortByCompletion = () => {
        setTodos([...todos].sort((a, b) => {
            // 未完了を先に、完了済みを後に
            if (a.completed === b.completed) return 0;
            return a.completed ? 1 : -1;
        }));
    };
    
    // ✅ 複合ソート(完了状態 → 日付)
    const sortByCompletionThenDate = () => {
        setTodos([...todos].sort((a, b) => {
            // 最初に完了状態でソート
            if (a.completed !== b.completed) {
                return a.completed ? 1 : -1;
            }
            // 同じ完了状態なら日付でソート
            return new Date(a.createdAt) - new Date(b.createdAt);
        }));
    };
    
    // ✅ カスタムソート関数
    const customSort = (sortFunction) => {
        setTodos(prevTodos => [...prevTodos].sort(sortFunction));
    };
    
    // ✅ ソート順序の切り替え
    const [sortOrder, setSortOrder] = useState('asc');
    
    const sortWithOrder = (field) => {
        const multiplier = sortOrder === 'asc' ? 1 : -1;
        
        setTodos([...todos].sort((a, b) => {
            if (field === 'text') {
                return a.text.localeCompare(b.text) * multiplier;
            }
            if (field === 'date') {
                return (new Date(a.createdAt) - new Date(b.createdAt)) * multiplier;
            }
            return 0;
        }));
        
        setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
    };
    
    return (
        <div>
            <h1>ソート機能付きTodoリスト</h1>
            
            <div>
                <button onClick={sortByText}>
                    テキスト順
                </button>
                <button onClick={sortByDate}>
                    日付順
                </button>
                <button onClick={sortByCompletion}>
                    完了状態順
                </button>
                <button onClick={sortByCompletionThenDate}>
                    完了状態 → 日付順
                </button>
                <button onClick={() => sortWithOrder('text')}>
                    テキスト順 ({sortOrder})
                </button>
            </div>
            
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>
                        <input
                            type="checkbox"
                            checked={todo.completed}
                            readOnly
                        />
                        {todo.text} - {todo.createdAt}
                    </li>
                ))}
            </ul>
        </div>
    );
}

[...todos].sort()の書き方がポイントです。

最初にスプレッド演算子で配列をコピーして、それからソートしています。 こうすることで、元の配列は変更されません。

複雑な配列操作のパターンをマスターしよう

実際の開発でよく使われる、より複雑な配列操作パターンをご紹介します。

これができるようになると、かなり実用的なアプリが作れるようになりますよ。

ネストした配列の操作

配列の中に配列がある場合の操作方法です。

function NestedArrayExample() {
    const [categories, setCategories] = useState([
        {
            id: 1,
            name: '仕事',
            todos: [
                { id: 101, text: '会議の準備', completed: false },
                { id: 102, text: '資料作成', completed: true }
            ]
        },
        {
            id: 2,
            name: 'プライベート',
            todos: [
                { id: 201, text: '買い物', completed: false },
                { id: 202, text: '掃除', completed: false }
            ]
        }
    ]);
    
    // ✅ ネストした配列に要素追加
    const addTodoToCategory = (categoryId, newTodo) => {
        setCategories(categories.map(category =>
            category.id === categoryId
                ? {
                    ...category,
                    todos: [...category.todos, newTodo]
                }
                : category
        ));
    };
    
    // ✅ ネストした配列から要素削除
    const deleteTodoFromCategory = (categoryId, todoId) => {
        setCategories(categories.map(category =>
            category.id === categoryId
                ? {
                    ...category,
                    todos: category.todos.filter(todo => todo.id !== todoId)
                }
                : category
        ));
    };
    
    // ✅ ネストした要素の更新
    const updateTodoInCategory = (categoryId, todoId, updates) => {
        setCategories(categories.map(category =>
            category.id === categoryId
                ? {
                    ...category,
                    todos: category.todos.map(todo =>
                        todo.id === todoId
                            ? { ...todo, ...updates }
                            : todo
                    )
                }
                : category
        ));
    };
    
    // ✅ カテゴリ間でのTodo移動
    const moveTodoBetweenCategories = (fromCategoryId, toCategoryId, todoId) => {
        // 移動するTodoを見つける
        const fromCategory = categories.find(cat => cat.id === fromCategoryId);
        const todoToMove = fromCategory?.todos.find(todo => todo.id === todoId);
        
        if (!todoToMove) return;
        
        setCategories(categories.map(category => {
            if (category.id === fromCategoryId) {
                // 移動元から削除
                return {
                    ...category,
                    todos: category.todos.filter(todo => todo.id !== todoId)
                };
            } else if (category.id === toCategoryId) {
                // 移動先に追加
                return {
                    ...category,
                    todos: [...category.todos, todoToMove]
                };
            }
            return category;
        }));
    };
    
    return (
        <div>
            <h1>カテゴリ別Todoリスト</h1>
            
            {categories.map(category => (
                <div key={category.id} className="category">
                    <h2>{category.name}</h2>
                    <ul>
                        {category.todos.map(todo => (
                            <li key={todo.id}>
                                <input
                                    type="checkbox"
                                    checked={todo.completed}
                                    onChange={() => updateTodoInCategory(
                                        category.id,
                                        todo.id,
                                        { completed: !todo.completed }
                                    )}
                                />
                                {todo.text}
                                <button onClick={() => deleteTodoFromCategory(
                                    category.id,
                                    todo.id
                                )}>
                                    削除
                                </button>
                            </li>
                        ))}
                    </ul>
                    
                    <button onClick={() => addTodoToCategory(category.id, {
                        id: Date.now(),
                        text: '新しいタスク',
                        completed: false
                    })}>
                        タスク追加
                    </button>
                </div>
            ))}
        </div>
    );
}

ネストした配列の操作では、外側の配列も内側の配列も両方とも新しく作る必要があります。

mapを2回使って、両方のレベルで不変性を保つのがポイントです。

配列の検索とフィルタリング

複数の条件でデータをフィルタリングする方法です。

function FilterableTodoList() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'React を学習する', completed: false, category: '学習', priority: 'high' },
        { id: 2, text: 'アプリを作る', completed: false, category: '開発', priority: 'medium' },
        { id: 3, text: 'テストを書く', completed: true, category: '開発', priority: 'low' },
        { id: 4, text: 'デプロイする', completed: false, category: '開発', priority: 'high' }
    ]);
    
    const [searchTerm, setSearchTerm] = useState('');
    const [categoryFilter, setCategoryFilter] = useState('all');
    const [completionFilter, setCompletionFilter] = useState('all');
    const [priorityFilter, setPriorityFilter] = useState('all');
    
    // ✅ 複数条件でのフィルタリング
    const filteredTodos = todos.filter(todo => {
        // テキスト検索
        const matchesSearch = todo.text.toLowerCase().includes(searchTerm.toLowerCase());
        
        // カテゴリフィルター
        const matchesCategory = categoryFilter === 'all' || todo.category === categoryFilter;
        
        // 完了状態フィルター
        const matchesCompletion = 
            completionFilter === 'all' ||
            (completionFilter === 'completed' && todo.completed) ||
            (completionFilter === 'active' && !todo.completed);
        
        // 優先度フィルター
        const matchesPriority = priorityFilter === 'all' || todo.priority === priorityFilter;
        
        return matchesSearch && matchesCategory && matchesCompletion && matchesPriority;
    });
    
    // ✅ 検索結果のハイライト
    const highlightSearchTerm = (text, searchTerm) => {
        if (!searchTerm) return text;
        
        const parts = text.split(new RegExp(`(${searchTerm})`, 'gi'));
        return parts.map((part, index) =>
            part.toLowerCase() === searchTerm.toLowerCase() ? (
                <mark key={index}>{part}</mark>
            ) : (
                part
            )
        );
    };
    
    // ✅ フィルター用の選択肢を動的に生成
    const uniqueCategories = [...new Set(todos.map(todo => todo.category))];
    const uniquePriorities = [...new Set(todos.map(todo => todo.priority))];
    
    // ✅ フィルターのリセット
    const resetFilters = () => {
        setSearchTerm('');
        setCategoryFilter('all');
        setCompletionFilter('all');
        setPriorityFilter('all');
    };
    
    return (
        <div>
            <h1>高度なフィルター機能付きTodoリスト</h1>
            
            {/* 検索・フィルターUI */}
            <div className="filters">
                <input
                    type="text"
                    placeholder="Todoを検索..."
                    value={searchTerm}
                    onChange={(e) => setSearchTerm(e.target.value)}
                />
                
                <select
                    value={categoryFilter}
                    onChange={(e) => setCategoryFilter(e.target.value)}
                >
                    <option value="all">すべてのカテゴリ</option>
                    {uniqueCategories.map(category => (
                        <option key={category} value={category}>
                            {category}
                        </option>
                    ))}
                </select>
                
                <select
                    value={completionFilter}
                    onChange={(e) => setCompletionFilter(e.target.value)}
                >
                    <option value="all">すべて</option>
                    <option value="active">未完了</option>
                    <option value="completed">完了済み</option>
                </select>
                
                <select
                    value={priorityFilter}
                    onChange={(e) => setPriorityFilter(e.target.value)}
                >
                    <option value="all">すべての優先度</option>
                    {uniquePriorities.map(priority => (
                        <option key={priority} value={priority}>
                            {priority}
                        </option>
                    ))}
                </select>
                
                <button onClick={resetFilters}>フィルターリセット</button>
            </div>
            
            {/* 検索結果表示 */}
            <div className="results-info">
                {filteredTodos.length} / {todos.length} 件の結果
            </div>
            
            {/* フィルター済みリスト表示 */}
            <ul>
                {filteredTodos.map(todo => (
                    <li key={todo.id}>
                        <input
                            type="checkbox"
                            checked={todo.completed}
                            onChange={() => {
                                setTodos(todos.map(t =>
                                    t.id === todo.id
                                        ? { ...t, completed: !t.completed }
                                        : t
                                ));
                            }}
                        />
                        <span>
                            {highlightSearchTerm(todo.text, searchTerm)}
                        </span>
                        <span className="category">#{todo.category}</span>
                        <span className={`priority priority-${todo.priority}`}>
                            {todo.priority}
                        </span>
                    </li>
                ))}
            </ul>
            
            {filteredTodos.length === 0 && (
                <div className="no-results">
                    検索条件に合うTodoが見つかりません
                </div>
            )}
        </div>
    );
}

フィルタリングでは、複数の条件をすべて満たすものだけを表示します。

&&演算子を使って、すべての条件がtrueの場合のみ残すようにしています。 検索結果のハイライトも、ユーザビリティを高める良い機能ですね。

配列の並び替え(ドラッグ&ドロップ)

ユーザーが手動で順番を変更できる機能です。

function DragAndDropTodoList() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'React を学習する', order: 0 },
        { id: 2, text: 'アプリを作る', order: 1 },
        { id: 3, text: 'テストを書く', order: 2 },
        { id: 4, text: 'デプロイする', order: 3 }
    ]);
    
    const [draggedItem, setDraggedItem] = useState(null);
    
    // ✅ 配列要素の位置変更
    const moveItem = (fromIndex, toIndex) => {
        setTodos(prevTodos => {
            const newTodos = [...prevTodos];
            const [removed] = newTodos.splice(fromIndex, 1);
            newTodos.splice(toIndex, 0, removed);
            
            // order プロパティを更新
            return newTodos.map((todo, index) => ({
                ...todo,
                order: index
            }));
        });
    };
    
    // ✅ ドラッグ開始
    const handleDragStart = (e, index) => {
        setDraggedItem(index);
        e.dataTransfer.effectAllowed = 'move';
    };
    
    // ✅ ドラッグオーバー
    const handleDragOver = (e) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
    };
    
    // ✅ ドロップ
    const handleDrop = (e, dropIndex) => {
        e.preventDefault();
        
        if (draggedItem !== null && draggedItem !== dropIndex) {
            moveItem(draggedItem, dropIndex);
        }
        
        setDraggedItem(null);
    };
    
    // ✅ 上下移動ボタン
    const moveUp = (index) => {
        if (index > 0) {
            moveItem(index, index - 1);
        }
    };
    
    const moveDown = (index) => {
        if (index < todos.length - 1) {
            moveItem(index, index + 1);
        }
    };
    
    // ✅ 先頭・末尾への移動
    const moveToTop = (index) => {
        moveItem(index, 0);
    };
    
    const moveToBottom = (index) => {
        moveItem(index, todos.length - 1);
    };
    
    return (
        <div>
            <h1>並び替え可能なTodoリスト</h1>
            
            <ul>
                {todos.map((todo, index) => (
                    <li
                        key={todo.id}
                        draggable
                        onDragStart={(e) => handleDragStart(e, index)}
                        onDragOver={handleDragOver}
                        onDrop={(e) => handleDrop(e, index)}
                        className={draggedItem === index ? 'dragging' : ''}
                    >
                        <span className="drag-handle">⋮⋮</span>
                        <span className="todo-text">{todo.text}</span>
                        <span className="order">順番: {todo.order}</span>
                        
                        <div className="move-buttons">
                            <button 
                                onClick={() => moveToTop(index)}
                                disabled={index === 0}
                            >
                                ⬆⬆ 先頭
                            </button>
                            <button 
                                onClick={() => moveUp(index)}
                                disabled={index === 0}
                            >
                                ⬆ 上
                            </button>
                            <button 
                                onClick={() => moveDown(index)}
                                disabled={index === todos.length - 1}
                            >
                                ⬇ 下
                            </button>
                            <button 
                                onClick={() => moveToBottom(index)}
                                disabled={index === todos.length - 1}
                            >
                                ⬇⬇ 末尾
                            </button>
                        </div>
                    </li>
                ))}
            </ul>
        </div>
    );
}

ドラッグ&ドロップでは、HTML5のDrag and Drop APIを使います。

spliceを使っていますが、これは配列をコピーしてから使っているので大丈夫です。 ボタンでの移動機能も併せて用意すると、使いやすくなりますね。

パフォーマンス最適化のテクニック

大量の配列データを扱う際のパフォーマンス最適化について説明します。

速度が遅くなってしまった時に役立ちますよ。

React.memoによる最適化

コンポーネントの無駄な再レンダリングを防ぐ方法です。

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

// ✅ メモ化されたTodoアイテム
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete, onEdit }) {
    console.log(`TodoItem ${todo.id} がレンダリングされました`);
    
    return (
        <li>
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
            />
            <span>{todo.text}</span>
            <button onClick={() => onEdit(todo.id)}>編集</button>
            <button onClick={() => onDelete(todo.id)}>削除</button>
        </li>
    );
});

function OptimizedTodoList() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'React を学習する', completed: false },
        { id: 2, text: 'アプリを作る', completed: false },
        { id: 3, text: 'テストを書く', completed: false }
    ]);
    
    // ✅ useCallback でイベントハンドラーを最適化
    const handleToggle = useCallback((id) => {
        setTodos(prevTodos =>
            prevTodos.map(todo =>
                todo.id === id
                    ? { ...todo, completed: !todo.completed }
                    : todo
            )
        );
    }, []);
    
    const handleDelete = useCallback((id) => {
        setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
    }, []);
    
    const handleEdit = useCallback((id) => {
        const newText = prompt('新しいテキストを入力してください');
        if (newText) {
            setTodos(prevTodos =>
                prevTodos.map(todo =>
                    todo.id === id
                        ? { ...todo, text: newText }
                        : todo
                )
            );
        }
    }, []);
    
    return (
        <div>
            <h1>最適化されたTodoリスト</h1>
            <ul>
                {todos.map(todo => (
                    <TodoItem
                        key={todo.id}
                        todo={todo}
                        onToggle={handleToggle}
                        onDelete={handleDelete}
                        onEdit={handleEdit}
                    />
                ))}
            </ul>
        </div>
    );
}

React.memoでコンポーネントをラップすると、propsが変わらない限り再レンダリングされません。

useCallbackでイベントハンドラーをメモ化することで、関数の参照が変わらなくなります。 これにより、TodoItemの無駄な再レンダリングを防げるんです。

debounceによる検索最適化

検索機能で、入力のたびに処理が走らないようにする方法です。

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

// debounce ユーティリティ関数
function useDebounce(value, delay) {
    const [debouncedValue, setDebouncedValue] = useState(value);
    
    React.useEffect(() => {
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);
        
        return () => {
            clearTimeout(handler);
        };
    }, [value, delay]);
    
    return debouncedValue;
}

function OptimizedSearchList() {
    const [items] = useState(() =>
        Array.from({ length: 1000 }, (_, i) => ({
            id: i,
            name: `商品 ${i + 1}`,
            description: `これは商品 ${i + 1} の説明です`,
            price: Math.floor(Math.random() * 10000) + 1000,
            category: ['電子機器', '衣類', '本', '食品'][i % 4]
        }))
    );
    
    const [searchTerm, setSearchTerm] = useState('');
    const [categoryFilter, setCategoryFilter] = useState('all');
    const [priceRange, setPriceRange] = useState([0, 20000]);
    
    // ✅ 検索語句をデバウンス
    const debouncedSearchTerm = useDebounce(searchTerm, 300);
    
    // ✅ フィルタリング処理をメモ化
    const filteredItems = useMemo(() => {
        console.log('フィルタリング実行中...');
        
        return items.filter(item => {
            // テキスト検索
            const matchesSearch = debouncedSearchTerm === '' ||
                item.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
                item.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase());
            
            // カテゴリフィルター
            const matchesCategory = categoryFilter === 'all' || item.category === categoryFilter;
            
            // 価格範囲フィルター
            const matchesPrice = item.price >= priceRange[0] && item.price <= priceRange[1];
            
            return matchesSearch && matchesCategory && matchesPrice;
        });
    }, [items, debouncedSearchTerm, categoryFilter, priceRange]);
    
    // ✅ ソート処理もメモ化
    const [sortBy, setSortBy] = useState('name');
    const [sortOrder, setSortOrder] = useState('asc');
    
    const sortedItems = useMemo(() => {
        console.log('ソート実行中...');
        
        const sorted = [...filteredItems].sort((a, b) => {
            let aValue = a[sortBy];
            let bValue = b[sortBy];
            
            if (typeof aValue === 'string') {
                aValue = aValue.toLowerCase();
                bValue = bValue.toLowerCase();
            }
            
            if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1;
            if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1;
            return 0;
        });
        
        return sorted;
    }, [filteredItems, sortBy, sortOrder]);
    
    // ✅ イベントハンドラーをメモ化
    const handleSortChange = useCallback((field) => {
        if (sortBy === field) {
            setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
        } else {
            setSortBy(field);
            setSortOrder('asc');
        }
    }, [sortBy]);
    
    return (
        <div>
            <h1>最適化された検索リスト</h1>
            
            {/* 検索・フィルターコントロール */}
            <div className="controls">
                <input
                    type="text"
                    placeholder="商品を検索..."
                    value={searchTerm}
                    onChange={(e) => setSearchTerm(e.target.value)}
                />
                
                <select
                    value={categoryFilter}
                    onChange={(e) => setCategoryFilter(e.target.value)}
                >
                    <option value="all">すべてのカテゴリ</option>
                    <option value="電子機器">電子機器</option>
                    <option value="衣類">衣類</option>
                    <option value="本">本</option>
                    <option value="食品">食品</option>
                </select>
                
                <div>
                    価格範囲: ¥{priceRange[0]} - ¥{priceRange[1]}
                    <input
                        type="range"
                        min="0"
                        max="20000"
                        value={priceRange[0]}
                        onChange={(e) => setPriceRange([+e.target.value, priceRange[1]])}
                    />
                    <input
                        type="range"
                        min="0"
                        max="20000"
                        value={priceRange[1]}
                        onChange={(e) => setPriceRange([priceRange[0], +e.target.value])}
                    />
                </div>
            </div>
            
            {/* ソートコントロール */}
            <div className="sort-controls">
                <button onClick={() => handleSortChange('name')}>
                    名前でソート {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
                </button>
                <button onClick={() => handleSortChange('price')}>
                    価格でソート {sortBy === 'price' && (sortOrder === 'asc' ? '↑' : '↓')}
                </button>
                <button onClick={() => handleSortChange('category')}>
                    カテゴリでソート {sortBy === 'category' && (sortOrder === 'asc' ? '↑' : '↓')}
                </button>
            </div>
            
            {/* 結果表示 */}
            <p>{sortedItems.length} 件の結果</p>
            
            <ul>
                {sortedItems.map(item => (
                    <ItemDisplay key={item.id} item={item} />
                ))}
            </ul>
        </div>
    );
}

// ✅ アイテム表示コンポーネントをメモ化
const ItemDisplay = memo(function ItemDisplay({ item }) {
    return (
        <li>
            <h3>{item.name}</h3>
            <p>{item.description}</p>
            <p>価格: ¥{item.price.toLocaleString()}</p>
            <p>カテゴリ: {item.category}</p>
        </li>
    );
});

useDebounceで検索入力を300ミリ秒遅延させることで、入力中の無駄な処理を防げます。

useMemoでフィルタリングとソートの結果をメモ化すると、依存値が変わらない限り再計算されません。 大量のデータを扱う場合、これらの最適化は必須ですね。

よくあるエラーと解決方法を覚えよう

配列操作でよく遭遇するエラーパターンとその解決方法をご紹介します。

事前に知っておくと、つまずいた時にすぐ解決できますよ。

Key属性に関するエラー

Reactでリストを表示する時によく出るエラーです。

// ❌ 問題のあるコード
function BadKeyExample() {
    const [items, setItems] = useState(['A', 'B', 'C']);
    
    return (
        <ul>
            {items.map(item => (
                // Warning: Each child in a list should have a unique "key" prop
                <li>{item}</li>
            ))}
        </ul>
    );
}

// ✅ 修正版1: インデックスをkeyに使用(推奨されない)
function OkayKeyExample() {
    const [items, setItems] = useState(['A', 'B', 'C']);
    
    return (
        <ul>
            {items.map((item, index) => (
                <li key={index}>{item}</li>
            ))}
        </ul>
    );
}

// ✅ 修正版2: 一意のIDをkeyに使用(推奨)
function GoodKeyExample() {
    const [items, setItems] = useState([
        { id: 1, text: 'A' },
        { id: 2, text: 'B' },
        { id: 3, text: 'C' }
    ]);
    
    return (
        <ul>
            {items.map(item => (
                <li key={item.id}>{item.text}</li>
            ))}
        </ul>
    );
}

// ✅ 動的リストでの適切なkey設定
function DynamicKeyExample() {
    const [todos, setTodos] = useState([]);
    
    const addTodo = (text) => {
        const newTodo = {
            id: Date.now(), // または uuid() など
            text,
            completed: false
        };
        setTodos([...todos, newTodo]);
    };
    
    const deleteTodo = (id) => {
        setTodos(todos.filter(todo => todo.id !== id));
    };
    
    return (
        <div>
            <button onClick={() => addTodo('新しいTodo')}>
                Todo追加
            </button>
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>
                        {todo.text}
                        <button onClick={() => deleteTodo(todo.id)}>
                            削除
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    );
}

key属性は、Reactがどの要素が変更されたかを判断するために必要です。

一意のIDを使うのがベスト。 インデックスを使うと、順番が変わった時に予期しない動作をすることがあります。

非同期更新での競合状態

複数の非同期処理が同時に走った時の問題です。

// ❌ 問題のあるコード:競合状態が発生する可能性
function RaceConditionExample() {
    const [todos, setTodos] = useState([]);
    
    const addTodoAsync = async (text) => {
        // 非同期処理中に他の操作が実行される可能性
        const newTodo = await createTodo(text);
        setTodos([...todos, newTodo]); // 古いtodosを参照している可能性
    };
    
    return (
        <div>
            <button onClick={() => addTodoAsync('Todo 1')}>
                Todo 1 追加
            </button>
            <button onClick={() => addTodoAsync('Todo 2')}>
                Todo 2 追加
            </button>
        </div>
    );
}

// ✅ 修正版:関数型更新を使用
function FixedRaceConditionExample() {
    const [todos, setTodos] = useState([]);
    
    const addTodoAsync = async (text) => {
        const newTodo = await createTodo(text);
        setTodos(prevTodos => [...prevTodos, newTodo]); // 最新の状態を参照
    };
    
    // または useEffect + 状態フラグを使用
    const [isLoading, setIsLoading] = useState(false);
    
    const addTodoSafe = async (text) => {
        if (isLoading) return; // 重複実行を防ぐ
        
        setIsLoading(true);
        try {
            const newTodo = await createTodo(text);
            setTodos(prevTodos => [...prevTodos, newTodo]);
        } catch (error) {
            console.error('Todo追加エラー:', error);
        } finally {
            setIsLoading(false);
        }
    };
    
    return (
        <div>
            <button 
                onClick={() => addTodoSafe('Todo 1')}
                disabled={isLoading}
            >
                Todo 1 追加 {isLoading && '(処理中...)'}
            </button>
            <button 
                onClick={() => addTodoSafe('Todo 2')}
                disabled={isLoading}
            >
                Todo 2 追加
            </button>
        </div>
    );
}

// モック関数
async function createTodo(text) {
    // API呼び出しのシミュレーション
    await new Promise(resolve => setTimeout(resolve, 1000));
    return {
        id: Date.now(),
        text,
        completed: false
    };
}

非同期処理では、関数型更新(prevTodos => ...)を使うのが安全です。

これにより、常に最新の状態を参照できます。 ローディング状態を管理して、重複実行を防ぐのも効果的ですね。

まとめ:React配列操作をマスターしよう!

お疲れ様でした! Reactでの配列操作について詳しく解説してきました。

主要なポイント

配列操作で絶対に覚えておくべきことです。

  1. 不変性の重要性 - 配列を直接変更せず、新しい配列を作成する
  2. 正しい操作方法 - push/splice の代わりにスプレッド演算子やfilter/mapを使用
  3. パフォーマンス最適化 - React.memo、useCallback、useMemoを適切に活用
  4. エラー回避 - key属性、競合状態、メモリリークに注意

配列操作の基本ルール

覚えやすいパターンをまとめました。

  • 追加: [...array, newItem] または array.concat(newItem)
  • 削除: array.filter(item => item.id !== id)
  • 更新: array.map(item => item.id === id ? {...item, ...updates} : item)
  • ソート: [...array].sort(compareFn)

実践で使えるテクニック

実際の開発で役立つポイントです。

  • ネストした配列では両方のレベルで不変性を保つ
  • 複数条件のフィルタリングではすべての条件を&&で繋ぐ
  • ドラッグ&ドロップではHTML5 APIを活用
  • 大量データではメモ化とデバウンスを使用

適切な配列操作を身につけることで、Reactアプリケーションのパフォーマンスと保守性を大幅に向上させることができます。

不変性を意識し、正しい方法で配列のstateを更新することが、React開発の成功の鍵となります。

「最初は難しく感じる」かもしれませんが、パターンを覚えてしまえば簡単です。

ぜひ実際のプロジェクトでこれらの技術を活用してみてください。 きっと、スムーズで高性能なReactアプリが作れるようになりますよ!

関連記事