Rorkでアプリを作り始めたころ、状態管理には React の useContext と useState を組み合わせたシンプルな方法を使っていました。最初はそれで十分でした。画面が2〜3枚なら、Context APIで問題は起きません。
ところがスクリーン数が8枚を超えたあたりから、妙なことが起き始めました。ある画面でユーザーのプロフィール情報を更新すると、直接関係のない別の画面でも不要な再レンダリングが走る。デバッグしていくと、Context Providerの子孫コンポーネントが丸ごと再レンダリングされていることが分かりました。
そのとき、累計500万ダウンロードを超えたアプリ群を個人で開発・運営してきた経験から、「これは後で大きくなるほど辛くなるパターンだ」と直感的に分かりました。2014年から個人開発を続けてきた中で、初期の設計の甘さが後から雪だるま式に問題になっていく経験を何度もしてきたので。
Zustand v5を導入して1週間後、再レンダリングの問題はほぼ解消しました。その移行の経緯とZustand v5の実践的な使い方を実例とともに整理しました。
Context APIのどこが問題だったか
Context APIを使った状態管理の典型的なパターンはこんな形です。
// ❌ よくある問題パターン
const UserContext = createContext(null);
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [settings, setSettings] = useState({});
const [notifications, setNotifications] = useState([]);
return (
<UserContext.Provider value={{ user, setUser, settings, setSettings, notifications, setNotifications }}>
{children}
</UserContext.Provider>
);
}このパターンの問題は、user、settings、notifications のいずれかが変わるたびに、UserContext.Provider の 全ての子孫コンポーネント が再レンダリングされる点です。通知の件数が変わっただけで、ユーザープロフィールを表示しているコンポーネントも再描画されてしまう。
小さなアプリなら気にならないですが、画面数が増えると、フリーズとまではいかなくても「なんかもっさりするな」という体験劣化につながります。
Zustand v5とは、なぜRorkアプリに向いているか
Zustand(ドイツ語で「状態」)は、Reactのグローバル状態管理ライブラリの中でも軽量でシンプルな設計が特徴です。v5(2024年10月リリース)では、useSyncExternalStore を内部で使うようになり、React 18以降の並行モード(Concurrent Mode)と適切に動作するようになりました。
React Native / Rorkアプリに向いている理由はシンプルです。
- 選択的な購読(selective subscription): 必要な状態だけを購読できるため、無関係な更新で再レンダリングされない
- Provider不要: コンポーネントツリーをProviderで囲む必要がなく、どこからでも状態にアクセスできる
- 非同期処理がシンプル: Thunkやmiddlewareなしに非同期ロジックをstoreの中に書ける
- TypeScriptとの相性が良い: 型推論が自然に効く
インストールと基本的な使い方
Rorkプロジェクトへのインストールはシンプルです。
npx expo install zustandv5の基本的なstoreの書き方はこうなります。
// store/useUserStore.ts
import { create } from 'zustand';
type User = {
id: string;
name: string;
email: string;
};
type UserStore = {
user: User | null;
isLoading: boolean;
setUser: (user: User | null) => void;
fetchUser: (userId: string) => Promise<void>;
};
export const useUserStore = create<UserStore>((set) => ({
user: null,
isLoading: false,
setUser: (user) => set({ user }),
fetchUser: async (userId) => {
set({ isLoading: true });
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
set({ user: data, isLoading: false });
} catch (error) {
set({ isLoading: false });
console.error('fetchUser failed:', error);
}
},
}));コンポーネントからの使い方は直感的です。
// screens/ProfileScreen.tsx
import { useUserStore } from '../store/useUserStore';
export default function ProfileScreen() {
// ⭐ userだけを選択購読 — isLoadingが変わっても再レンダリングされない
const user = useUserStore((state) => state.user);
const fetchUser = useUserStore((state) => state.fetchUser);
// ...
}(state) => state.user という セレクタ関数 がポイントです。このコンポーネントは user が変わったときだけ再レンダリングされ、isLoading の変化では再レンダリングされません。Context APIとの決定的な違いはここにあります。
v4からv5への主な変更点
Zustand v4を使ったことがある方向けに、v5で変わった部分をまとめます。
① create の挙動が変わった
v4では set の第二引数に true を渡すと state を完全に置き換えできました。v5では明示的に replace: true と書く必要があります。
// v4の書き方(v5でも動くが非推奨)
set({ user: null }, true);
// v5の推奨
set({ user: null }, { replace: true });② subscribeWithSelector がコア統合
v4では subscribeWithSelector をmiddlewareとして追加する必要がありましたが、v5では subscribe が直接セレクタをサポートします。
// v5: セレクタを直接指定して購読
const unsubscribe = useUserStore.subscribe(
(state) => state.user?.id,
(userId) => {
console.log('ユーザーIDが変わりました:', userId);
}
);③ TypeScriptの型推論が改善
v5では create<UserStore>()() の二重括弧が不要になり、型推論がよりスマートになりました。TypeScriptで書く場合の記述量が少し減っています。
実践パターン:認証状態の管理
RorkアプリでZustandを使う典型的なパターンとして、認証(Auth)状態の管理を紹介します。
// store/useAuthStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
type AuthState = {
token: string | null;
isAuthenticated: boolean;
signIn: (token: string) => void;
signOut: () => void;
};
// persist middlewareでAsyncStorageに永続化
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
isAuthenticated: false,
signIn: (token) => set({ token, isAuthenticated: true }),
signOut: () => set({ token: null, isAuthenticated: false }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);この実装では、persist middlewareを使うことでアプリを再起動してもログイン状態が保持されます。AsyncStorage をストレージとして指定しているので、React Nativeの環境でそのまま動きます。
アプリのルートでの使い方はこうなります。
// app/_layout.tsx(Expo Router)
import { useAuthStore } from '../store/useAuthStore';
import { Redirect } from 'expo-router';
export default function RootLayout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
if (!isAuthenticated) {
return <Redirect href="/login" />;
}
return <Slot />;
}Provider不要でルートのレイアウトから状態を読み取れるのは、Rorkで Expo Router を使ったナビゲーション設計 を構築するうえで特に便利です。
Context APIとZustand、どちらを選ぶべきか
「じゃあもうContextは使わなくていい?」という疑問への答えは「ケース・バイ・ケース」です。
Contextが適しているケース:
- テーマ(ダークモード/ライトモード)のような静的に近い設定値
- コンポーネントツリーの一部分だけで使うローカルな状態
- サードパーティライブラリが提供するProviderパターン
Zustandが適しているケース:
- ユーザー認証状態、カート内容のようなグローバルかつ頻繁に変わる状態
- 複数の画面から参照・更新される状態
- 非同期API呼び出しを含む状態ロジック
私の場合、現在は「Zustand for グローバル状態、Context for テーマ・i18n・ライブラリProvider」という分け方に落ち着いています。TanStack QueryとZustandを組み合わせる パターンも実用的で、サーバーデータはTanStack Query、クライアントのUI状態はZustandという役割分担がきれいに機能します。
移行するなら少しずつが安全
既存のContext APIをいきなり全部Zustandに書き換えるのはリスクがあります。私がお勧めするのは、「最も再レンダリングが多発している状態から1つずつ移行する」方法です。
- React DevTools(またはRork CompanionのパフォーマンスパネルでのFlipperを活用)で不要な再レンダリングを特定
- 原因となっているContextを1つ選んでZustandに移行
- 移行後の動作を確認してから次へ
Rorkアプリの規模が大きくなってきたら、状態管理の見直しは避けて通れません。Zustand v5は、個人開発者が一人で管理できる軽さと、スケールしたときに耐えられる設計のバランスが取れたライブラリだと感じています。
まずは認証状態の管理だけZustandに移行してみると、効果を実感しやすいと思います。