Reactで変数が更新されない|イミュータブルの考え方

Reactで変数が更新されない原因とイミュータブルな状態管理について解説。正しいstate更新方法を初心者向けに詳しく説明します。

Learning Next 運営
32 分で読めます

みなさん、Reactで状態を変更したのに「画面が更新されない」という経験はありませんか?

「変数を変更したのに画面に反映されない」 「配列に要素を追加したけど表示されない」 「オブジェクトのプロパティを変えても何も起こらない」

こんな悩みを抱えている方も多いはずです。

実は、これはReactのイミュータブル(不変性)という重要な概念を理解していないために起こる問題なんです。 この記事では、Reactで変数が更新されない原因と、イミュータブルな状態管理の考え方を詳しく解説します。

正しいstate更新方法をマスターして、期待通りに画面が更新されるReactアプリを作れるようになりましょう。 ぜひ最後まで読んで、Reactの状態管理をマスターしてくださいね!

なぜReactで変数が更新されないの?

まず、なぜReactで変数が更新されないのか、その原因を理解しましょう。 実は、とても重要な理由があるんです。

よくある間違い:直接変更してしまう

こんなコードを書いたことはありませんか?

// ❌ 直接stateを変更している(間違い)
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '買い物', completed: false },
    { id: 2, text: '勉強', completed: false }
  ]);

  const toggleTodo = (id) => {
    // 直接配列の要素を変更
    const todo = todos.find(todo => todo.id === id);
    todo.completed = !todo.completed; // ❌ 直接変更
    
    setTodos(todos); // 画面が更新されない!
  };

  return (
    <ul>
      {todos.map(todo => (
        <li 
          key={todo.id}
          onClick={() => toggleTodo(todo.id)}
          style={{
            textDecoration: todo.completed ? 'line-through' : 'none'
          }}
        >
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

このコードでは、既存のオブジェクトを直接変更しています。 でも、なぜかクリックしてもToDoの状態が変わりませんよね。

Reactが変更を検知する仕組み

Reactが変更を検知する仕組みを理解しましょう。

// React内部での比較(簡略化)
function useState(initialValue) {
  let currentValue = initialValue;
  
  const setValue = (newValue) => {
    // 参照の比較で変更を検知
    if (currentValue !== newValue) { // Object.is() による比較
      currentValue = newValue;
      rerender(); // 再レンダリング実行
    }
  };
  
  return [currentValue, setValue];
}

Reactは参照の比較(===)で変更を検知します。 同じオブジェクトを変更しても、参照は同じなので変更として認識されないんです。

簡単に言うと

  • 新しいオブジェクト・配列 → 「変更があった!」→ 画面更新
  • 既存のオブジェクト・配列を変更 → 「変更なし」→ 画面更新なし

これがReactの基本的な動作原理です。

イミュータブル(不変性)って何?

イミュータブルな状態管理の概念を詳しく見てみましょう。 この考え方がReactの核心なんです。

ミュータブル vs イミュータブル

まず、2つの違いを理解しましょう。

// ❌ ミュータブル(可変)な操作
const user = { name: 'Alice', age: 25 };
user.age = 26; // 既存オブジェクトを直接変更

const numbers = [1, 2, 3];
numbers.push(4); // 既存配列を直接変更

// ✅ イミュータブル(不変)な操作
const user = { name: 'Alice', age: 25 };
const updatedUser = { ...user, age: 26 }; // 新しいオブジェクトを作成

const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4]; // 新しい配列を作成

ミュータブル(可変)

既存のデータを直接変更する方法です。 Reactでは期待通りに動作しません。

イミュータブル(不変)

既存のデータを変更せず、新しいデータを作成する方法です。 Reactはこの方法を期待しています。

なぜイミュータブルが重要なの?

Reactがイミュータブルを重視する理由を理解しましょう。

// Reactの内部的な仕組み
function Component() {
  const [state, setState] = useState(initialState);
  
  // Reactは参照を比較して再レンダリングを判断
  const handleUpdate = () => {
    // ❌ 同じ参照のため変更が検知されない
    state.value = 'new value';
    setState(state);
    
    // ✅ 新しい参照のため変更が検知される
    setState({ ...state, value: 'new value' });
  };
}

イミュータブルな更新により、以下のメリットがあります。

  • 変更検知が高速: 参照比較だけで済む
  • 予測可能な動作: 副作用が少ない
  • デバッグが簡単: 状態の変化が追跡しやすい
  • パフォーマンス向上: 必要な時のみ再レンダリング

これらの理由から、Reactではイミュータブルな更新が推奨されているんです。

正しいstate更新方法をマスターしよう

具体的な正しいstate更新方法を見てみましょう。 パターン別に解説していきますね。

プリミティブ値の更新は簡単

数値、文字列、booleanなどのプリミティブ値は簡単です。

function Counter() {
  const [count, setCount] = useState(0);
  
  const increment = () => {
    // ✅ プリミティブ値は直接設定
    setCount(count + 1);
    
    // または関数形式
    setCount(prevCount => prevCount + 1);
  };
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}

プリミティブ値(数値、文字列、boolean)は直接設定できます。 これらは元々イミュータブルな性質を持っているからですね。

オブジェクトの更新にはスプレッド構文

オブジェクトの更新では、スプレッド構文が活躍します。

function UserProfile() {
  const [user, setUser] = useState({
    name: 'Alice',
    age: 25,
    email: 'alice@example.com'
  });
  
  const updateName = (newName) => {
    // ✅ スプレッド構文で新しいオブジェクト作成
    setUser({ ...user, name: newName });
  };
  
  const updateAge = () => {
    // ✅ 複数のプロパティを更新
    setUser({
      ...user,
      age: user.age + 1,
      lastUpdated: new Date()
    });
  };
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>年齢: {user.age}歳</p>
      <p>メール: {user.email}</p>
      <button onClick={() => updateName('Bob')}>名前を変更</button>
      <button onClick={updateAge}>年齢+1</button>
    </div>
  );
}

スプレッド構文(...)を使うことで、既存のプロパティをコピーしつつ、特定のプロパティだけを更新できます。 とても便利ですよね。

ネストしたオブジェクトは階層ごとに新しく作成

ネストしたオブジェクトは少し複雑ですが、同じ原理です。

function UserSettings() {
  const [user, setUser] = useState({
    name: 'Alice',
    preferences: {
      theme: 'light',
      language: 'ja',
      notifications: {
        email: true,
        push: false
      }
    }
  });
  
  const updateTheme = (newTheme) => {
    // ✅ ネストしたオブジェクトも新しく作成
    setUser({
      ...user,
      preferences: {
        ...user.preferences,
        theme: newTheme
      }
    });
  };
  
  const updateEmailNotification = (enabled) => {
    // ✅ 深いネストも同様に
    setUser({
      ...user,
      preferences: {
        ...user.preferences,
        notifications: {
          ...user.preferences.notifications,
          email: enabled
        }
      }
    });
  };
  
  return (
    <div>
      <p>テーマ: {user.preferences.theme}</p>
      <button onClick={() => updateTheme('dark')}>
        ダークテーマ
      </button>
      
      <p>Email通知: {user.preferences.notifications.email ? 'ON' : 'OFF'}</p>
      <button onClick={() => updateEmailNotification(!user.preferences.notifications.email)}>
        Email通知切り替え
      </button>
    </div>
  );
}

ネストしたオブジェクトでは、各階層で新しいオブジェクトを作成する必要があります。 少し大変ですが、これがReactの基本ルールです。

配列の更新パターンをマスターしよう

配列の更新では、map、filter、スプレッド構文を活用します。

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '買い物', completed: false },
    { id: 2, text: '勉強', completed: false }
  ]);
  
  // ✅ 新しいtodoを追加
  const addTodo = (text) => {
    const newTodo = {
      id: Date.now(),
      text,
      completed: false
    };
    setTodos([...todos, newTodo]);
  };
  
  // ✅ todoを削除
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };
  
  // ✅ todoの状態を変更
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
    ));
  };
  
  // ✅ todoを編集
  const editTodo = (id, newText) => {
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, text: newText }
        : todo
    ));
  };
  
  return (
    <div>
      {todos.map(todo => (
        <div key={todo.id}>
          <span 
            style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
              cursor: 'pointer'
            }}
            onClick={() => toggleTodo(todo.id)}
          >
            {todo.text}
          </span>
          <button onClick={() => deleteTodo(todo.id)}>削除</button>
        </div>
      ))}
      <button onClick={() => addTodo('新しいタスク')}>
        タスク追加
      </button>
    </div>
  );
}

配列の操作では、以下のメソッドを使い分けます。

  • 追加: スプレッド構文([...array, newItem]
  • 削除: filter(array.filter(condition)
  • 更新: map(array.map(updateFunction)

これらを組み合わせることで、どんな配列操作も可能です。

複雑な状態更新パターンを理解しよう

もう少し複雑なパターンも見てみましょう。 実際のアプリケーションでよく使われる例です。

配列内のオブジェクト更新

配列の中にオブジェクトがある場合の更新方法です。

function UserList() {
  const [users, setUsers] = useState([
    { id: 1, name: 'Alice', active: true },
    { id: 2, name: 'Bob', active: false },
    { id: 3, name: 'Charlie', active: true }
  ]);
  
  const toggleUserStatus = (userId) => {
    setUsers(users.map(user =>
      user.id === userId
        ? { ...user, active: !user.active }
        : user
    ));
  };
  
  const updateUserName = (userId, newName) => {
    setUsers(users.map(user =>
      user.id === userId
        ? { ...user, name: newName }
        : user
    ));
  };
  
  const addUser = (name) => {
    const newUser = {
      id: Math.max(...users.map(u => u.id)) + 1,
      name,
      active: true
    };
    setUsers([...users, newUser]);
  };
  
  return (
    <div>
      {users.map(user => (
        <div key={user.id}>
          <span style={{ color: user.active ? 'green' : 'gray' }}>
            {user.name}
          </span>
          <button onClick={() => toggleUserStatus(user.id)}>
            {user.active ? '無効化' : '有効化'}
          </button>
          <button onClick={() => updateUserName(user.id, prompt('新しい名前'))}>
            名前変更
          </button>
        </div>
      ))}
      <button onClick={() => addUser(prompt('ユーザー名'))}>
        ユーザー追加
      </button>
    </div>
  );
}

配列内のオブジェクトを更新する場合も、map関数とスプレッド構文を組み合わせます。 対象のオブジェクトのみ新しく作成し、他はそのまま返すのがポイントです。

条件付き更新パターン

条件によって更新内容を変える場合の実装例です。

function ConditionalUpdate() {
  const [data, setData] = useState({
    user: { name: 'Alice', age: 25 },
    settings: { theme: 'light' },
    isLoggedIn: false
  });
  
  const login = (user) => {
    setData({
      ...data,
      user,
      isLoggedIn: true
    });
  };
  
  const updateTheme = (theme) => {
    // ログイン時のみテーマを更新
    if (data.isLoggedIn) {
      setData({
        ...data,
        settings: {
          ...data.settings,
          theme
        }
      });
    }
  };
  
  const incrementAge = () => {
    setData({
      ...data,
      user: {
        ...data.user,
        age: data.user.age + 1
      }
    });
  };
  
  return (
    <div>
      {data.isLoggedIn ? (
        <div>
          <p>ユーザー: {data.user.name} ({data.user.age}歳)</p>
          <p>テーマ: {data.settings.theme}</p>
          <button onClick={() => updateTheme('dark')}>
            ダークテーマ
          </button>
          <button onClick={incrementAge}>年齢+1</button>
        </div>
      ) : (
        <button onClick={() => login({ name: 'Alice', age: 25 })}>
          ログイン
        </button>
      )}
    </div>
  );
}

条件付きの更新でも、基本原則は同じです。 イミュータブルな方法で状態を変更することが大切ですね。

useReducerで複雑な状態を管理しよう

状態が複雑になってきたら、useReducerが便利です。 より組織的に状態管理ができるようになりますよ。

reducerによる状態管理

useReducerを使った本格的な例を見てみましょう。

import { useReducer } from 'react';

// アクションタイプの定義
const actionTypes = {
  ADD_TODO: 'ADD_TODO',
  TOGGLE_TODO: 'TOGGLE_TODO',
  DELETE_TODO: 'DELETE_TODO',
  EDIT_TODO: 'EDIT_TODO',
  SET_FILTER: 'SET_FILTER'
};

// reducer関数
function todoReducer(state, action) {
  switch (action.type) {
    case actionTypes.ADD_TODO:
      return {
        ...state,
        todos: [...state.todos, {
          id: Date.now(),
          text: action.payload,
          completed: false
        }]
      };
      
    case actionTypes.TOGGLE_TODO:
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
      
    case actionTypes.DELETE_TODO:
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload)
      };
      
    case actionTypes.EDIT_TODO:
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, text: action.payload.text }
            : todo
        )
      };
      
    case actionTypes.SET_FILTER:
      return {
        ...state,
        filter: action.payload
      };
      
    default:
      return state;
  }
}

function TodoApp() {
  const initialState = {
    todos: [],
    filter: 'all' // 'all', 'active', 'completed'
  };
  
  const [state, dispatch] = useReducer(todoReducer, initialState);
  
  const addTodo = (text) => {
    dispatch({ type: actionTypes.ADD_TODO, payload: text });
  };
  
  const toggleTodo = (id) => {
    dispatch({ type: actionTypes.TOGGLE_TODO, payload: id });
  };
  
  const deleteTodo = (id) => {
    dispatch({ type: actionTypes.DELETE_TODO, payload: id });
  };
  
  return (
    <div>
      <input 
        onKeyPress={(e) => {
          if (e.key === 'Enter' && e.target.value.trim()) {
            addTodo(e.target.value.trim());
            e.target.value = '';
          }
        }}
        placeholder="新しいタスクを入力"
      />
      
      {state.todos.map(todo => (
        <div key={todo.id}>
          <span 
            style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
              cursor: 'pointer'
            }}
            onClick={() => toggleTodo(todo.id)}
          >
            {todo.text}
          </span>
          <button onClick={() => deleteTodo(todo.id)}>削除</button>
        </div>
      ))}
    </div>
  );
}

useReducerを使うことで、複雑な状態更新ロジックを整理できます。 すべての状態変更が予測可能で、デバッグもしやすくなりますね。

useReducerのメリット

  • 状態更新ロジックの集約: すべての更新が一箇所にまとまる
  • 予測可能な変更: actionとreducerで動作が明確
  • テストしやすい: reducerは純粋関数なのでテストが簡単
  • デバッグしやすい: action履歴が追跡できる

パフォーマンス最適化のコツ

イミュータブルな更新でパフォーマンスを向上させる方法をご紹介します。 適切な最適化で、より快適なアプリケーションを作りましょう。

React.memoで不要な再レンダリングを防ぐ

コンポーネントをメモ化することで、無駄な再レンダリングを防げます。

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

// ✅ メモ化されたコンポーネント
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
  console.log(`TodoItem ${todo.id} がレンダリングされました`);
  
  return (
    <div>
      <span 
        style={{
          textDecoration: todo.completed ? 'line-through' : 'none',
          cursor: 'pointer'
        }}
        onClick={() => onToggle(todo.id)}
      >
        {todo.text}
      </span>
      <button onClick={() => onDelete(todo.id)}>削除</button>
    </div>
  );
});

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'タスク1', completed: false },
    { id: 2, text: 'タスク2', completed: false }
  ]);
  
  const toggleTodo = useCallback((id) => {
    setTodos(todos => todos.map(todo =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
    ));
  }, []);
  
  const deleteTodo = useCallback((id) => {
    setTodos(todos => todos.filter(todo => todo.id !== id));
  }, []);
  
  return (
    <div>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={toggleTodo}
          onDelete={deleteTodo}
        />
      ))}
    </div>
  );
}

React.memoとuseCallbackを組み合わせることで、変更されたコンポーネントのみが再レンダリングされます。 大量のアイテムがある場合、効果が顕著に現れますよ。

useMemoで重い計算をメモ化

重い計算結果をキャッシュすることで、パフォーマンスを向上させられます。

import { useMemo } from 'react';

function ExpensiveList({ items, filter }) {
  // ✅ 重い計算をメモ化
  const filteredItems = useMemo(() => {
    console.log('フィルタリングを実行中...');
    return items.filter(item => {
      switch (filter) {
        case 'active':
          return !item.completed;
        case 'completed':
          return item.completed;
        default:
          return true;
      }
    }).sort((a, b) => a.text.localeCompare(b.text));
  }, [items, filter]);
  
  const stats = useMemo(() => {
    return {
      total: items.length,
      completed: items.filter(item => item.completed).length,
      active: items.filter(item => !item.completed).length
    };
  }, [items]);
  
  return (
    <div>
      <div>
        <p>全体: {stats.total}, 完了: {stats.completed}, 未完了: {stats.active}</p>
      </div>
      {filteredItems.map(item => (
        <div key={item.id}>{item.text}</div>
      ))}
    </div>
  );
}

useMemoを使うことで、依存する値が変わった時のみ計算が実行されます。 フィルタリングやソートなど、重い処理には特に効果的ですね。

デバッグ方法をマスターしよう

イミュータブルな更新が正しく行われているかのデバッグ方法をご紹介します。 問題を早期発見できるようになりますよ。

ログで状態変化を確認

console.logを使って状態の変化を追跡しましょう。

function DebuggableComponent() {
  const [state, setState] = useState({ count: 0, data: [] });
  
  const updateCount = () => {
    console.log('更新前の状態:', state);
    
    const newState = { ...state, count: state.count + 1 };
    console.log('更新後の状態:', newState);
    console.log('参照が変わったか:', state !== newState);
    
    setState(newState);
  };
  
  // useEffectで状態変化を監視
  useEffect(() => {
    console.log('状態が変更されました:', state);
  }, [state]);
  
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={updateCount}>更新</button>
    </div>
  );
}

ログで状態の変化と参照の変更を確認できます。 正しくイミュータブルな更新ができているかチェックしましょう。

React Developer Toolsを活用

React Developer ToolsのProfilerで、不要な再レンダリングを検出できます。

// React Developer Toolsでの確認ポイント
function MonitoredComponent() {
  const [users, setUsers] = useState([
    { id: 1, name: 'Alice' }
  ]);
  
  // Profilerで再レンダリングの原因を確認
  const updateUser = (id, newName) => {
    // 正しい更新方法
    setUsers(users => users.map(user =>
      user.id === id ? { ...user, name: newName } : user
    ));
  };
  
  return (
    <div>
      {users.map(user => (
        <div key={user.id}>
          {user.name}
          <button onClick={() => updateUser(user.id, 'Bob')}>
            名前変更
          </button>
        </div>
      ))}
    </div>
  );
}

React Developer ToolsのProfilerで、どのコンポーネントがいつ再レンダリングされているかを可視化できます。 パフォーマンスの問題を特定するのに便利ですよ。

まとめ:安全で効率的な状態管理をマスターしよう

Reactにおけるイミュータブルな状態管理について詳しく解説しました。 これらの概念を理解することで、期待通りに動作するReactアプリケーションを作れるようになります。

重要なポイント

  • Reactは参照の比較で変更を検知: 同じオブジェクトを変更しても検知されない
  • 既存のオブジェクトや配列を直接変更してはいけない: 必ず新しいものを作成
  • スプレッド構文が強力: オブジェクトも配列も新しく作成できる
  • ネストしたデータでは各階層で新しいオブジェクトを作成: 深い階層も忘れずに
  • useReducerで複雑な状態更新を整理: 大規模なアプリケーションに効果的
  • メモ化でパフォーマンス最適化: React.memo、useCallback、useMemoを活用

実践のステップ

  1. 基本パターンをマスター: まずはシンプルな更新から
  2. 複雑なパターンに挑戦: ネストしたオブジェクトや配列操作
  3. useReducerで整理: 状態が複雑になったら導入
  4. パフォーマンス最適化: メモ化を適切に活用

まずは基本的なスプレッド構文から始めて、徐々に複雑なパターンも扱えるようになるのがおすすめです。

最初は「なぜこんなに面倒なの?」と思うかもしれません。 でも、この仕組みを理解することで、より安定で効率的なReactアプリケーションを構築できるようになります。

ぜひ今回紹介した方法を実践して、Reactの状態管理をマスターしてくださいね! きっと、より良いReactアプリケーションを作れるようになるはずですよ。

関連記事