SF
藤本祥の記録

ZustandとReact Nativeで陥りやすい罠:ストア更新がコンポーネントに反映されない問題の完全解説

Published: 2025/1/22

はじめに

React NativeアプリケーションでZustandを使った状態管理を行っていると、「ストアを更新したはずなのに、コンポーネントが再レンダリングされない」という問題に遭遇することがあります。

今回は、実際に遭遇した「コンポーネントの表示条件は満たしているのに、画面が切り替わらない」という問題を通じて、Reactの再レンダリングの仕組みとZustandの動作原理を深掘りし、根本的な解決策を解説します。

問題の概要

以下のような画面遷移を実装していました:

  1. DialogueView(会話画面)
  2. QuestionView(質問画面)← ここが表示されない!

ZustandストアでcurrentPhase'question'に更新しているにも関わらず、QuestionViewが表示されない問題が発生しました。

Reactの再レンダリングの基本原理

まず、Reactの再レンダリングについて理解することが重要です。

1. 再レンダリングが発生する条件

Reactコンポーネントが再レンダリングされるのは、以下の場合です:

// 1. state が更新された場合
const [count, setCount] = useState(0);
setCount(1); // 再レンダリング発生

// 2. props が変更された場合
<ChildComponent name="John" /> // nameが変わると子が再レンダリング

// 3. 親コンポーネントが再レンダリングされた場合
// 親が再レンダリング → 子も再レンダリング(React.memoを使わない限り)

// 4. Context の値が変更された場合
const value = useContext(MyContext); // Context値が変わると再レンダリング

// 5. カスタムフックの返り値が変更された場合
const data = useCustomHook(); // フック内のstateが変わると再レンダリング

2. propsの更新メカニズム

ここが今回の問題の核心です。propsは親から子への一方通行です:

// 親コンポーネント
function ParentComponent() {
  const [user, setUser] = useState({ name: 'Alice', age: 30 });
  
  return <ChildComponent user={user} />;
}

// 子コンポーネント
function ChildComponent({ user }) {
  // userは親が再レンダリングして新しい値を渡すまで更新されない
  console.log(user); // 常に親から渡された時点の値
}

親の再レンダリングが子に伝わらない問題の詳細解説

なぜ親が再レンダリングしても子のpropsが更新されないのか

これは今回の問題の核心であり、React開発者が必ず理解すべき重要なポイントです。

原因1: JavaScriptの参照の仕組み

// この問題を理解するための基本例
const useStore = create((set) => ({
  users: [{ id: 1, name: 'Alice', age: 30 }],
  updateUser: (id, updates) => set((state) => ({
    users: state.users.map(user => 
      user.id === id ? { ...user, ...updates } : user
    )
  }))
}));

function Parent() {
  const users = useStore(state => state.users);
  const selectedUser = users.find(u => u.id === 1);
  
  // 問題のポイント:findの返り値は配列内のオブジェクトへの参照
  console.log('Parent render:', selectedUser);
  
  return <Child user={selectedUser} />;
}

function Child({ user }) {
  console.log('Child render:', user);
  
  // userを更新してもこのコンポーネントのpropsは変わらない!
  const updateUser = useStore(state => state.updateUser);
  
  return (
    <button onClick={() => updateUser(user.id, { age: 31 })}>
      Update Age
    </button>
  );
}

何が起きているか(ステップバイステップ)

// Step 1: 初期状態
Store: users = [{ id: 1, name: 'Alice', age: 30 }]
Parent: selectedUser = users[0] // 参照: 0x001
Child: props.user = 0x001

// Step 2: 子コンポーネントでupdateUser実行
Store: users = [{ id: 1, name: 'Alice', age: 31 }] // 新しい配列
Parent: 再レンダリング発生
        selectedUser = users.find(...) // 新しいオブジェクト: 0x002

// Step 3: ここで問題発生!
// Reactは以下のような比較を行う
const shouldUpdateChild = Object.is(prevProps.user, nextProps.user);
// Object.is(0x001, 0x002) = false なので、本来は更新されるべき

// しかし、React/React Nativeの最適化により、
// 場合によっては参照が同じと判断されることがある

原因2: React Nativeのバッチ処理と非同期更新

// React Nativeでは、パフォーマンスのために更新がバッチ処理される
function Parent() {
  const users = useStore(state => state.users);
  
  // findの結果がメモ化されていない場合、
  // 毎回新しい参照を返すはずだが...
  const selectedUser = users.find(u => u.id === 1);
  
  // React Nativeのブリッジを通る際に、
  // オブジェクトの同一性チェックが予期しない動作をする可能性
  return <Child user={selectedUser} />;
}

原因3: クロージャーの罠

function Child({ user }) {
  const updateUser = useStore(state => state.updateUser);
  
  const handleUpdate = () => {
    // このuser変数はpropsから来た古い値
    // クロージャーによってキャプチャされている
    console.log('Updating user:', user); // 古い値
    
    updateUser(user.id, { age: user.age + 1 });
    
    // 更新直後も、このコンポーネント内のuserは古いまま
    console.log('After update:', user); // まだ古い値!
  };
  
  return <button onClick={handleUpdate}>Update</button>;
}

解決方法1: 最新の値を確実に取得する

// ❌ 問題のあるパターン
function Parent() {
  const users = useStore(state => state.users);
  const selectedUser = users.find(u => u.id === 1);
  return <Child user={selectedUser} />;
}

// ✅ 解決パターン1: IDのみを渡す
function Parent() {
  const selectedUserId = 1;
  return <Child userId={selectedUserId} />;
}

function Child({ userId }) {
  // 常に最新の値を取得
  const user = useStore(state => 
    state.users.find(u => u.id === userId)
  );
  // ...
}

// ✅ 解決パターン2: keyを使って強制再マウント
function Parent() {
  const users = useStore(state => state.users);
  const selectedUser = users.find(u => u.id === 1);
  
  // userのバージョンをkeyとして使用
  return <Child key={`${selectedUser.id}-${selectedUser.age}`} user={selectedUser} />;
}

解決方法2: useSyncExternalStoreの活用

import { useSyncExternalStore } from 'react';

// カスタムフックで最新の値を保証
function useLatestUser(userId) {
  return useSyncExternalStore(
    // subscribe関数
    (callback) => {
      const unsubscribe = useStore.subscribe(callback);
      return unsubscribe;
    },
    // getSnapshot関数 - 常に最新の値を返す
    () => {
      const state = useStore.getState();
      return state.users.find(u => u.id === userId);
    }
  );
}

function Child({ userId }) {
  const user = useLatestUser(userId); // 常に最新!
  // ...
}

解決方法3: useEffectによる明示的な同期

function Child({ user: initialUser }) {
  const [currentUser, setCurrentUser] = useState(initialUser);
  const getUser = useStore(state => state.getUser);
  
  // propsの変更を検知して内部stateを更新
  useLayoutEffect(() => {
    // ストアから最新の値を取得
    const latestUser = getUser(initialUser.id);
    if (latestUser && !Object.is(latestUser, currentUser)) {
      setCurrentUser(latestUser);
    }
  }, [initialUser.id, initialUser.age]); // 依存配列を適切に設定
  
  return <div>{currentUser.name}: {currentUser.age}</div>;
}

解決方法4: Zustandのsubscribeを直接使用

function Child({ userId }) {
  const [user, setUser] = useState(() => {
    const state = useStore.getState();
    return state.users.find(u => u.id === userId);
  });
  
  useEffect(() => {
    // ストアの変更を直接購読
    const unsubscribe = useStore.subscribe((state) => {
      const updatedUser = state.users.find(u => u.id === userId);
      setUser(updatedUser);
    });
    
    return unsubscribe;
  }, [userId]);
  
  return <div>{user?.name}: {user?.age}</div>;
}

今後の開発での教訓

1. 設計原則

// 🎯 原則: "Single Source of Truth"
// データの実体は一箇所(ストア)にのみ存在させる

// ❌ アンチパターン
<Parent>
  <Child user={userObject} />  // オブジェクトを渡す
</Parent>

// ✅ ベストプラクティス
<Parent>
  <Child userId={userId} />    // IDを渡す
</Parent>

2. デバッグ時のチェックリスト

// デバッグ用のカスタムフック
function useDebugProps(name, props) {
  const prevPropsRef = useRef();
  
  useEffect(() => {
    if (prevPropsRef.current) {
      const changes = {};
      for (const key in props) {
        if (!Object.is(prevPropsRef.current[key], props[key])) {
          changes[key] = {
            from: prevPropsRef.current[key],
            to: props[key]
          };
        }
      }
      if (Object.keys(changes).length > 0) {
        console.log(`[${name}] Props changed:`, changes);
      }
    }
    prevPropsRef.current = props;
  });
}

// 使用例
function Child({ user }) {
  useDebugProps('Child', { user });
  // ...
}

3. テストの書き方

// 親の再レンダリングが子に伝わることを確認するテスト
import { renderHook, act } from '@testing-library/react-hooks';

test('子コンポーネントが最新のストア値を取得できる', () => {
  const { result } = renderHook(() => useStore());
  
  // 初期状態の確認
  expect(result.current.users[0].age).toBe(30);
  
  // 更新
  act(() => {
    result.current.updateUser(1, { age: 31 });
  });
  
  // 更新後の確認
  expect(result.current.users[0].age).toBe(31);
  
  // 子コンポーネントでも同じ値が取得できることを確認
  const { result: childResult } = renderHook(() => 
    useStore(state => state.users.find(u => u.id === 1))
  );
  
  expect(childResult.current.age).toBe(31);
});

まとめ:親子間のデータ同期を確実にする方法

  1. IDベースの設計を徹底する - オブジェクトではなくIDを渡す
  2. ストアから直接購読する - 子コンポーネントでもストアにアクセス
  3. keyプロップを活用する - 必要に応じて強制的に再マウント
  4. デバッグツールを使う - React DevToolsで実際の更新を確認
  5. テストを書く - 親子間のデータ同期をテストで保証

これらの原則を守ることで、「親が更新されたのに子が更新されない」という問題を防ぐことができます。

Zustandの動作原理

Zustandは、Reactの外部で状態を管理します。

1. 基本的な仕組み

// ストアの定義
import { create } from 'zustand';

const useStore = create((set, get) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  getBears: () => get().bears,
}));

2. 購読(Subscription)の仕組み

Zustandの重要な概念は購読です:

// コンポーネントA - ストアを購読している
function ComponentA() {
  const bears = useStore((state) => state.bears); // 購読
  return <div>Bears: {bears}</div>;
  // bearsが変わると自動的に再レンダリング
}

// コンポーネントB - propsで受け取っている
function ComponentB({ bears }) {
  return <div>Bears: {bears}</div>;
  // 親が新しいbearsを渡さない限り更新されない
}

問題の根本原因

今回の問題を簡略化したコードで再現してみましょう:

// ストア定義
const useGameStore = create((set) => ({
  characters: [],
  updateCharacter: (id, updates) => set((state) => ({
    characters: state.characters.map((char) =>
      char.id === id ? { ...char, ...updates } : char
    ),
  })),
}));

// 親コンポーネント
function GameScreen() {
  const characters = useGameStore((state) => state.characters);
  const selectedCharacter = characters.find((c) => c.id === 'player1');
  
  // ⚠️ ここで問題発生!
  // selectedCharacterをpropsとして渡している
  return <GameView character={selectedCharacter} />;
}

// 子コンポーネント
function GameView({ character }) {
  const updateCharacter = useGameStore((state) => state.updateCharacter);
  
  // 現在のフェーズに応じて画面を切り替え
  if (character.phase === 'dialogue') {
    return (
      <DialogueScreen
        onComplete={() => {
          // ストアを更新
          updateCharacter(character.id, { phase: 'question' });
          // ⚠️ しかし、このコンポーネントのcharacter propsは古いまま!
        }}
      />
    );
  }
  
  if (character.phase === 'question') {
    // ここに到達しない!
    // なぜなら、character.phaseはまだ'dialogue'のまま
    return <QuestionScreen />;
  }
}

何が起きているのか?

  1. 初期状態: character.phase = 'dialogue'
  2. ストア更新: updateCharacterphase: 'question'に更新
  3. 親の再レンダリング: GameScreencharactersを購読しているので再レンダリング
  4. 問題発生:
    • React Nativeの最適化により、propsの参照が同じと判断される可能性
    • または、非同期処理のタイミング問題
    • 結果として、GameViewcharacter propsが古い値のまま

図解:データフローの問題

[Zustand Store]
  characters: [{ id: 'player1', phase: 'question' }] ← 更新済み!
   (subscribe)
  [GameScreen]
    ・characters を再取得
    ・selectedCharacter を再計算
    ・再レンダリング
    (props)
  [GameView]
    character: { id: 'player1', phase: 'dialogue' } ← 古い値のまま!?

解決策

解決策1: ストアから直接取得(推奨)

function GameView({ characterId }) {  // IDだけを受け取る
  // ストアから常に最新のデータを取得
  const character = useGameStore((state) => 
    state.characters.find(c => c.id === characterId)
  );
  const updateCharacter = useGameStore((state) => state.updateCharacter);
  
  // 以降、常に最新のcharacterを参照
  if (character.phase === 'dialogue') {
    return (
      <DialogueScreen
        onComplete={() => {
          updateCharacter(character.id, { phase: 'question' });
          // 次の再レンダリングで最新のphaseが反映される
        }}
      />
    );
  }
  
  if (character.phase === 'question') {
    return <QuestionScreen />; // 正しく表示される!
  }
}

解決策2: セレクターパターン

// カスタムフックでセレクターを定義
function useCharacter(characterId) {
  return useGameStore((state) => 
    state.characters.find(c => c.id === characterId)
  );
}

function GameView({ characterId }) {
  const character = useCharacter(characterId); // 常に最新
  const updateCharacter = useGameStore((state) => state.updateCharacter);
  
  // 以下同じ
}

解決策3: 浅い比較を使用

import { shallow } from 'zustand/shallow';

function GameView({ characterId }) {
  // 浅い比較で必要な部分だけ購読
  const { phase, name } = useGameStore(
    (state) => {
      const char = state.characters.find(c => c.id === characterId);
      return { phase: char?.phase, name: char?.name };
    },
    shallow // 浅い比較を使用
  );
  
  if (phase === 'question') {
    return <QuestionScreen />;
  }
}

解決策4: useEffectで同期(非推奨だが理解のために)

function GameView({ character: initialCharacter }) {
  const [character, setCharacter] = useState(initialCharacter);
  const getCharacter = useGameStore((state) => state.getCharacter);
  
  // propsの変更を検知して内部stateを更新
  useEffect(() => {
    const interval = setInterval(() => {
      const latest = getCharacter(initialCharacter.id);
      if (latest && latest.phase !== character.phase) {
        setCharacter(latest);
      }
    }, 100);
    
    return () => clearInterval(interval);
  }, [initialCharacter.id, character.phase]);
  
  // 非効率的で推奨されない
}

ベストプラクティス

1. IDベースの設計

// ❌ 悪い例:オブジェクト全体をpropsで渡す
<GameView character={selectedCharacter} />

// ✅ 良い例:IDだけを渡す
<GameView characterId={selectedCharacter.id} />

2. セレクターの活用

// ストアに専用のセレクターを定義
const useGameStore = create((set, get) => ({
  characters: [],
  
  // セレクター
  getCharacterById: (id) => {
    return get().characters.find(c => c.id === id);
  },
  
  // 更新関数
  updateCharacter: (id, updates) => {
    set((state) => ({
      characters: state.characters.map((char) =>
        char.id === id ? { ...char, ...updates } : char
      ),
    }));
  },
}));

3. React.memoとuseMemoの適切な使用

// 子コンポーネントの不要な再レンダリングを防ぐ
const GameView = React.memo(({ characterId }) => {
  const character = useGameStore((state) => 
    state.characters.find(c => c.id === characterId)
  );
  
  // 重い計算はメモ化
  const processedData = useMemo(() => {
    return heavyProcessing(character);
  }, [character]);
  
  return <div>{/* ... */}</div>;
});

4. 購読の最小化

// ❌ 悪い例:ストア全体を購読
const store = useGameStore();

// ✅ 良い例:必要な部分だけ購読
const phase = useGameStore((state) => 
  state.characters.find(c => c.id === id)?.phase
);

デバッグのコツ

1. React DevToolsの活用

// コンポーネントに名前を付ける
GameView.displayName = 'GameView';

// デバッグ用のログ
function GameView({ characterId }) {
  const character = useGameStore((state) => 
    state.characters.find(c => c.id === characterId)
  );
  
  // レンダリング時にログ出力
  console.log('GameView render:', {
    characterId,
    phase: character?.phase,
    timestamp: Date.now()
  });
  
  return <div>{/* ... */}</div>;
}

2. Zustand DevToolsの設定

import { devtools } from 'zustand/middleware';

const useGameStore = create(
  devtools(
    (set) => ({
      // ストア定義
    }),
    {
      name: 'game-store', // DevToolsでの表示名
    }
  )
);

パフォーマンスの最適化

1. 選択的な購読

// 特定のキャラクターの変更だけを監視
function useCharacterPhase(characterId) {
  return useGameStore((state) => {
    const char = state.characters.find(c => c.id === characterId);
    return char?.phase;
  });
}

2. バッチ更新

// 複数の更新をまとめる
const updateMultiple = () => {
  useGameStore.setState((state) => ({
    characters: state.characters.map((char) => {
      if (char.id === 'player1') {
        return { ...char, phase: 'question', score: 100 };
      }
      return char;
    }),
    globalScore: state.globalScore + 100,
  }));
};

まとめ

React NativeとZustandを組み合わせて使う際は、以下の点に注意が必要です:

  1. propsとストアの購読の違いを理解する

    • propsは親の再レンダリングに依存
    • ストアの購読は直接的な更新通知
  2. IDベースの設計を採用する

    • オブジェクト全体ではなくIDを渡す
    • 子コンポーネントで最新データを取得
  3. 購読を最小化する

    • 必要な部分だけを購読
    • パフォーマンスの最適化
  4. デバッグツールを活用する

    • React DevTools
    • Zustand DevTools
    • 適切なログ出力

これらの原則を守ることで、予測可能で保守しやすいReact Native + Zustandアプリケーションを構築できます。

参考リンク