はじめに
React NativeアプリケーションでZustandを使った状態管理を行っていると、「ストアを更新したはずなのに、コンポーネントが再レンダリングされない」という問題に遭遇することがあります。
今回は、実際に遭遇した「コンポーネントの表示条件は満たしているのに、画面が切り替わらない」という問題を通じて、Reactの再レンダリングの仕組みとZustandの動作原理を深掘りし、根本的な解決策を解説します。
問題の概要
以下のような画面遷移を実装していました:
- DialogueView(会話画面)
- 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);
});
まとめ:親子間のデータ同期を確実にする方法
- IDベースの設計を徹底する - オブジェクトではなくIDを渡す
- ストアから直接購読する - 子コンポーネントでもストアにアクセス
- keyプロップを活用する - 必要に応じて強制的に再マウント
- デバッグツールを使う - React DevToolsで実際の更新を確認
- テストを書く - 親子間のデータ同期をテストで保証
これらの原則を守ることで、「親が更新されたのに子が更新されない」という問題を防ぐことができます。
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 />;
}
}
何が起きているのか?
- 初期状態:
character.phase = 'dialogue' - ストア更新:
updateCharacterでphase: 'question'に更新 - 親の再レンダリング:
GameScreenはcharactersを購読しているので再レンダリング - 問題発生:
- React Nativeの最適化により、propsの参照が同じと判断される可能性
- または、非同期処理のタイミング問題
- 結果として、
GameViewのcharacterpropsが古い値のまま
図解:データフローの問題
[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を組み合わせて使う際は、以下の点に注意が必要です:
-
propsとストアの購読の違いを理解する
- propsは親の再レンダリングに依存
- ストアの購読は直接的な更新通知
-
IDベースの設計を採用する
- オブジェクト全体ではなくIDを渡す
- 子コンポーネントで最新データを取得
-
購読を最小化する
- 必要な部分だけを購読
- パフォーマンスの最適化
-
デバッグツールを活用する
- React DevTools
- Zustand DevTools
- 適切なログ出力
これらの原則を守ることで、予測可能で保守しやすいReact Native + Zustandアプリケーションを構築できます。