コンテンツにスキップ
Webフロントエンドエンジニアのための React Native 実践入門
Esc
navigateopen⌘Jpreview
このページの内容

第14章 状態管理と永続化

Zustandのストアにpersistミドルウェアを噛ませて、端末を再起動してもTODOが残る土台を作ります。

土台になるデータ層から作っていきます。TODOアプリで管理するのは、TODOのリストとユーザー設定(テーマの好み)の2つだけです。どちらも端末再起動後に残ってほしいので、Zustandのストアにpersistミドルウェアを噛ませてAsyncStorageに繋ぎます。

なぜZustandなのか

第10章でも軽く触れましたが、いくつかの選択肢の中からZustandを選ぶ理由を整理しておきます。

  • 必要なAPIがcreateuseStoreの2つだけで覚えやすい
  • セレクタを書けば必要な部分だけサブスクライブできて再レンダーが減らせる
  • persistミドルウェアでAsyncStorage(やSecureStoreなど)への保存が数行で書ける
  • React外からもuseStore.getState()で参照できるので、画面の外で値を使うときに困らない

ReduxやJotaiも有力な選択肢で、好みの問題と言って良い領域です。本書ではコード量が一番少なくなる組み合わせとしてZustandを採用します。

TODOの型を決める

最初にデータの形を固めます。src/types/todo.tsに置きます。

// src/types/todo.ts
export type Todo = {
  id: string;
  title: string;
  completed: boolean;
  createdAt: number;
  remindAt?: number | null;
  notificationId?: string | null;
};

createdAtDateオブジェクトではなくUNIXミリ秒(number)で持っているのは、JSON経由でストレージに保存するときに型が崩れないようにするためです。DateJSON.stringifyに渡すとDate.toJSON()が呼ばれてISO 8601文字列に変わり、JSON.parseで戻したときにはstringのままになります。AsyncStorageは内部でJSON.stringifyを使うので、Dateで保存すると読み出すたびにnew Date(...)で復元する手間が常につきまといます。最初からnumberで持つのが結局一番ラクです。

remindAtはリマインダーの予定時刻、notificationIdは予約済みのローカル通知のIDです。本章ではTODOの追加・編集の基本形にフォーカスし、リマインダー機能のロジックは章末で扱います。最初はnullのまま無視して読み進めてもらって構いません。

idはサーバー連携がない前提で、生成時刻+ランダムな文字列の素朴な仕組みで十分です。

const generateId = () =>
  `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;

本格的にバックエンドと同期する場合は、UUIDライブラリ(uuidexpo-cryptorandomUUID)を入れた方が安全です。

TODOストアを作る

src/stores/todos.tsにZustandストアを書きます。アクションは「追加」「部分更新」「完了トグル」「削除」の4つです。

// src/stores/todos.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";

import type { Todo } from "@/types/todo";

type TodosState = {
  todos: Todo[];
  addTodo: (title: string) => string;
  updateTodo: (
    id: string,
    patch: Partial<Pick<Todo, "title" | "completed">>,
  ) => void;
  toggleTodo: (id: string) => void;
  removeTodo: (id: string) => void;
};

const generateId = () =>
  `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;

export const useTodosStore = create<TodosState>()(
  persist(
    (set) => ({
      todos: [],
      addTodo: (title) => {
        const id = generateId();
        set((state) => ({
          todos: [
            ...state.todos,
            {
              id,
              title,
              completed: false,
              createdAt: Date.now(),
              remindAt: null,
              notificationId: null,
            },
          ],
        }));
        return id;
      },
      updateTodo: (id, patch) =>
        set((state) => ({
          todos: state.todos.map((todo) =>
            todo.id === id ? { ...todo, ...patch } : todo,
          ),
        })),
      toggleTodo: (id) =>
        set((state) => ({
          todos: state.todos.map((todo) =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo,
          ),
        })),
      removeTodo: (id) =>
        set((state) => ({
          todos: state.todos.filter((todo) => todo.id !== id),
        })),
    }),
    {
      name: "sample-todo-app/todos",
      storage: createJSONStorage(() => AsyncStorage),
    },
  ),
);

ポイントを順に見ていきます。

Stateとアクションを同じ型に置く

ZustandはReduxのようにreducer/actionを分離しません。TodosStateの中にデータと、それを変更する関数の両方を並べて書きます。コンポーネントからはuseTodosStore((s) => s.addTodo)のようにセレクタで取り出します。

updateTodoの引数をPartial<Pick<Todo, "title" | "completed">>にしているのは、編集画面でタイトルだけ変えたい・完了状態だけ変えたいといった部分更新を素直に書けるようにするためです。

addTodostring(新しいID)を返すようにしているのも意図的です。「TODOを追加した直後に、そのTODOにリマインダーを紐づけたい」のように、追加結果のIDを呼び出し側で必要とするケースがあるためです。setを呼んだ後のstate.todos[state.todos.length - 1].idに頼るより、IDをそのまま返したほうが見通しが良くなります。

永続化はpersistミドルウェアに任せる

persistをかけると、setで状態が変わるたびにストレージへの書き出しが走り、初回マウント時に読み出してハイドレートしてくれます。nameはAsyncStorageのキーで、アプリのバンドルID代わりに識別子をプレフィックスとして付けておくと、他のアプリと混ざらず安心です。

createJSONStorage(() => AsyncStorage)は、Zustandが期待するストレージインターフェースとAsyncStorageのインターフェースを橋渡しするためのアダプタです。差し替えたい場合(SecureStore、MMKVなど)も、ここを変えるだけで済みます。

型引数の二重括弧

create<TodosState>()(...)のようにcreateを呼ぶ前に空括弧が一段挟まっています。これはZustandがミドルウェア対応のために要求する書き方で、ミドルウェアを使うときは必ずこの形になります。覚えるしかない部分です。

設定ストアを作る

ユーザー設定(現状はテーマだけ)も同じ作りでsrc/stores/preferences.tsに置きます。

// src/stores/preferences.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";

export type ThemePreference = "system" | "light" | "dark";

type PreferencesState = {
  themePreference: ThemePreference;
  setThemePreference: (value: ThemePreference) => void;
};

export const usePreferencesStore = create<PreferencesState>()(
  persist(
    (set) => ({
      themePreference: "system",
      setThemePreference: (value) => set({ themePreference: value }),
    }),
    {
      name: "sample-todo-app/preferences",
      storage: createJSONStorage(() => AsyncStorage),
    },
  ),
);

themePreference"system" | "light" | "dark"の3値です。「システム設定に従う」を初期値にしておくと、OSの自動切り替えがそのまま反映されて好印象です。

ストアを2つに分けたのは、保存先のキーを分けたかったのと、変更頻度や寿命が違う(TODOは増減し、設定はほぼ変わらない)からです。同じストアに混ぜても動きはしますが、規模が大きくなるとマージしにくくなります。

ストアを画面から使う

実際の使い方をひとつだけ先取りしておきます(画面の実装は次章でやります)。

import { useTodosStore } from "@/stores/todos";

function TodoListScreen() {
  // 必要な部分だけセレクタで取り出す
  const todos = useTodosStore((state) => state.todos);
  const toggleTodo = useTodosStore((state) => state.toggleTodo);

  return (
    /* ... 一覧の描画 ... */
  );
}

セレクタを書く意味は再レンダーの最小化です。useTodosStore()を引数なしで呼ぶと、ストアのどこが変わっても再レンダーされてしまいます。「この画面はtodosとtoggleTodoしか使わない」と分かっているなら、それぞれ個別に取り出すのが基本です。

ストアをテストする

ロジックの単純なストアでも、テストを1つ書いておくと安心感が変わります。Zustandのストアは外部依存が薄いので、ファイルを直接importしてgetState()/setState()で操作できます。

Jestの最小セットアップ

テストを書く前に、Jestを動かす最低限の設定を済ませておきます。jest-expoプリセットを使うと設定は短くて済みます。

{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "preset": "jest-expo",
    "setupFiles": ["./jest.setup.ts"],
    "transformIgnorePatterns": [
      "node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|zustand))"
    ],
    "testPathIgnorePatterns": ["/node_modules/", "/example/", "/.rnstorybook/"]
  }
}

transformIgnorePatternsは、Jestがデフォルトで無視するnode_modules/の中で、ESM形式のままJestに食わせたいパッケージを通すための除外リストです。React Native関連ライブラリは多くがESMで配布されているので、まとめて入れておきます。testPathIgnorePatternsではexample/(create-expo-appの元テンプレ)と.rnstorybook/(第17章で自動生成)を除外しています。

persistミドルウェアがAsyncStorageに書き込む都合上、テスト中にAsyncStorageを公式のモックへ差し替えておく必要があります。jest-expoプリセット自体は自動でモックしてくれないので、jest.setup.tsで明示的に差し替えます。

// jest.setup.ts
import mockAsyncStorage from "@react-native-async-storage/async-storage/jest/async-storage-mock";

jest.mock("@react-native-async-storage/async-storage", () => mockAsyncStorage);

これでpersistミドルウェアもテスト中はメモリ上のオブジェクトに保存されるようになり、ファイルシステムを汚さずに済みます。Storybookとの連携やテスト設計の考え方は第17章でまとめて扱うので、ここでは動かすところまでにとどめます。

ストアのテストを書く

src/stores/todos.test.tsの例を抜粋します。

import { useTodosStore } from "./todos";

const resetStore = () => {
  useTodosStore.setState({ todos: [] });
};

describe("useTodosStore", () => {
  beforeEach(() => {
    resetStore();
  });

  it("新しい TODO を末尾に追加する", () => {
    useTodosStore.getState().addTodo("牛乳を買う");

    const { todos } = useTodosStore.getState();
    expect(todos).toHaveLength(1);
    expect(todos[0]).toMatchObject({
      title: "牛乳を買う",
      completed: false,
    });
  });

  it("指定した TODO の completed が反転する", () => {
    useTodosStore.getState().addTodo("運動する");
    const target = useTodosStore.getState().todos[0]!;

    useTodosStore.getState().toggleTodo(target.id);
    expect(useTodosStore.getState().todos[0]?.completed).toBe(true);
  });
});

ポイントはbeforeEachでストアをリセットすることです。Zustandのストアはモジュールスコープなので、テスト間で状態が持ち越されないように毎回初期化します。

テストの実行はnpm testです。「永続化されたデータがテストに残らないか」が心配になるかもしれませんが、上のjest.setup.tsでAsyncStorageを明示的にモックへ差し替えているため、ファイルシステムには残りません。

AsyncStorageの落とし穴

AsyncStorageは扱いやすいぶん、注意点もいくつかあります。実装中にハマりがちな2つだけ挙げておきます。

ひとつは読み込みは非同期であることです。アプリ起動直後はZustandのtodosが空配列のままで、persistの読み出しが終わるとリストが反映されます。一覧画面では一瞬「何もない」状態が見えるかもしれません。気になる場合はpersist.onFinishHydrationuseTodosStore.persist.hasHydrated()でハイドレーション完了を待つ実装にできますが、TODOアプリ程度の規模では体感できないので本書では割愛します。

Zustand persistの起動時ハイドレーションと保存タイミング

起動直後からハイドレーション完了までの間はtodosが初期値のままになる点と、setのたびにAsyncStorageへ書き込みが走るサイクルを図にしたものです。

もうひとつは容量制限です。AsyncStorageはAndroidでデフォルト6MBの制限があり(SQLiteベースの実装に由来)、AsyncStorage_db_size_in_MBで拡張できますが、ストア全体で超えないように設計するのが無難です。iOSは明確な上限はなく端末ストレージに依存します。TODOのテキストだけならまず問題になりませんが、画像のbase64などを直接放り込むとすぐ溢れます。大きなデータはexpo-file-systemでファイルとして保存し、AsyncStorageにはパスだけを置く設計にしてください。

副作用を伴うアクション ― リマインダー

ここまでのストアは「データの形を変える」だけの純粋な動きでしたが、実アプリではアクションの一部に副作用が入ってきます。サンプルアプリでは、TODOにリマインダーを設定できるようにしてあります。remindAt(通知時刻)とnotificationId(expo-notificationsから返ってくる予約済み通知のID)が、ここで活きてきます。

通知の予約・解除そのものはsrc/lib/notifications.tsに切り出し、ストアからは関数を呼ぶだけにしておきます(関数の中身は次章で扱います)。

// src/stores/todos.ts に追加
import {
  cancelTodoReminder,
  scheduleTodoReminder,
} from "@/lib/notifications";

type TodosState = {
  // ... 既存のアクション
  setReminder: (id: string, remindAt: number | null) => Promise<void>;
};

// store 定義の中に追加
setReminder: async (id, remindAt) => {
  const target = get().todos.find((todo) => todo.id === id);
  if (!target) return;

  await cancelTodoReminder(target.notificationId);

  let notificationId: string | null = null;
  if (remindAt && remindAt > Date.now()) {
    notificationId = await scheduleTodoReminder(
      target.title,
      new Date(remindAt),
    );
  }

  set((state) => ({
    todos: state.todos.map((todo) =>
      todo.id === id
        ? {
            ...todo,
            remindAt: notificationId ? remindAt : null,
            notificationId,
          }
        : todo,
    ),
  }));
},

ポイントは3つあります。

ひとつめは、setに渡す関数の代わりにgetが使えることです。Zustandのcreate((set, get) => (...))の第2引数が現在のstateを返す関数で、副作用を呼ぶ前に「対象のTODOがどんな状態か」を取りに行けます。「すでに予約されている通知をキャンセルしてから新しい通知を予約する」のような処理に必要です。

ふたつめは、完了トグル/削除でも通知を解除する点です。リマインダーを設定したTODOを完了にしたとき、または削除したときに通知が残ったままだと、後で意味のない通知が飛んでしまいます。toggleTodoremoveTodoの中でもcancelTodoReminderを呼ぶように手を入れます。

toggleTodo: (id) => {
  const target = get().todos.find((todo) => todo.id === id);
  if (target && !target.completed && target.notificationId) {
    // 完了になるタイミングでリマインダーを解除
    void cancelTodoReminder(target.notificationId);
  }
  set((state) => ({
    todos: state.todos.map((todo) =>
      todo.id === id
        ? {
            ...todo,
            completed: !todo.completed,
            ...(todo.completed
              ? {}
              : { remindAt: null, notificationId: null }),
          }
        : todo,
    ),
  }));
},
removeTodo: (id) => {
  const target = get().todos.find((todo) => todo.id === id);
  if (target?.notificationId) {
    void cancelTodoReminder(target.notificationId);
  }
  set((state) => ({
    todos: state.todos.filter((todo) => todo.id !== id),
  }));
},

voidを頭に付けているのは、Promiseを意図的に待たないことを明示するためです。通知のキャンセルが少し遅れても、ストアの状態更新は止めたくないので、エラー時もログを残す程度で先に進めます。

3つめは、通知連携をテスタブルに保つことです。@/lib/notificationsをJestのモックに差し替えれば、ストアのロジックだけをテストできます。jest.setup.tsに次の1行を足すだけで、テスト中はネイティブを呼ばないモック実装に切り替わります。

// jest.setup.ts
jest.mock("@/lib/notifications", () => ({
  ensureNotificationPermission: jest.fn(async () => true),
  scheduleTodoReminder: jest.fn(async () => "test-notification-id"),
  cancelTodoReminder: jest.fn(async () => undefined),
}));

「ストアからネイティブAPIを直接叩かない」という線を引いておくと、こうしたテストの取り回しが一気にラクになります。ライブラリ側を直接モックすることもできますが、expo-notificationsの型に縛られるより、自分で書いた薄い関数のシグネチャに合わせてモックを書くほうが寿命が長くなります。


データ層は以上です。次章ではこのストアを画面に繋いで、TODO一覧、編集モーダル、設定画面を組み立てていきます。リマインダー設定のUIも、その流れの中で扱います。

最終更新 2026年7月24日