Use Cases (更新: 2026/6/7)

Zustandの状態管理が重くならない設計:セレクタとpersistの勘所

Zustandの使い方を、ストア定義・セレクタで再レンダリングを絞る・persistの安全な永続化・非同期の楽観更新まで、コピペで動くTypeScriptで解説します。

Zustandの状態管理が重くならない設計:セレクタとpersistの勘所

「Zustandで状態管理して」とClaude Codeに頼んだら、5分でストアができました。

便利だなと思って管理画面に組み込み、フィルタもカートも認証の状態も、ぜんぶそこに突っ込みました。最初は快適でした。問題が出たのは、画面に通知バッジを足したときです。トーストが1件増えるたびに、関係ないテーブル全体が再描画されて、スクロールがカクつくようになったんです。

原因はZustandではありませんでした。僕が「何を入れるか」と「どう読むか」を決めずに、出てきたコードをそのまま使ったせいです。Zustandは軽いぶん、設計の手抜きがそのまま速度に出ます。

この記事では、僕が同じ事故を二度起こさないために固めた手順を、コピペで動くTypeScriptと一緒に置いておきます。

この記事の要点

  • Zustandに入れるのは「離れた画面で共有する値」と「URLで表せないUI状態」だけ。サーバーから取った一覧はキャッシュ管理が要るので別扱いにする。
  • 画面が重くなる犯人のほとんどはセレクタ不足。ストア全体を購読せず、必要な値だけを選び、複数返すときは useShallow で浅い比較にする。
  • persist は便利だが、partialize で保存対象を絞らないと個人情報がlocalStorageに残る。保存していいのは匿名のUI設定だけ。
  • 非同期の楽観更新は、requestIdと「戻す値」をセットで持たせないと、古いレスポンスが新しい状態を上書きする。
  • ReduxやContextと迷ったら、ボイラープレートの量と再レンダリング制御のどちらを取るかで決める。Zustandは両方を軽く済ませたいときの選択肢。

Zustandって、要するに何をするものか

Zustandは、Reactの状態管理ライブラリです。状態管理というのは、画面をまたいで共有したい値と、その値を変える処理を、一か所に集めて整理することを指します。

たとえばヘッダーのカートバッジと、別ページのカート画面。この2つは同じ「カートの中身」を見たいけれど、コンポーネントの親子関係としては遠く離れています。props で値を延々とバケツリレーするのはつらい。かといってグローバル変数に入れると、変わっても画面が再描画されません。

そこで、共有したい値を入れておく小さな箱を作り、その箱を購読しているコンポーネントだけが、値の変化で自動的に再描画される。この箱がストア(store)です。Zustandは、この箱を数行で作れて、しかも useContext のような大げさな仕組みを持ち込まずに済むのが売りです。公式の使い方はZustand公式ドキュメントにまとまっています。

ただ、軽いことには裏返しがあります。Reduxのように「こう書け」という型が緩いぶん、設計を自分で決めないと、さっきの僕のように何でも箱に放り込んで詰みます。だから最初に境界を引きます。

ストアに入れる状態と、入れない状態

僕がいま使っている判断基準は単純です。「複数の離れたコンポーネントで同じ値を使う」「URLだけでは表しにくいUI状態がある」「更新処理をひとつの場所でテストしたい」。このどれかに当てはまるものだけストアに入れます。

逆に、サーバーから取ってきた商品一覧やユーザー一覧は入れません。これらはキャッシュ、再取得、古くなった判定(stale)が必要で、それを自前のストアで抱えると地獄を見ます。サーバー由来のデータは専用の道具に任せ、Zustandには画面の都合(UI状態)だけを置く。この線引きが全部の土台です。

ユースケースストアに入れる値入れない値Claude Codeへの伝え方
管理画面フィルタkeyword、status、page、pageSizeAPIレスポンス全体URL同期する項目とUIだけの項目を分ける
カートSKU、数量、表示用の合計決済セッション、在庫の真実localStorageへ保存する項目を限定する
認証のUI状態ログインダイアログの開閉、確認中表示アクセストークン、メール、住所個人情報はストアにもpersistにも入れない
モーダル/トースト開いているモーダル、トーストの行列長文エラーログ、監査ログ画面に出す短い文だけ保持する
楽観更新処理中のrequestId、直前の表示状態サーバーの最終確定データ失敗時に戻す値と競合条件を指定する

この表を最初に書くだけで、後から「localStorageに個人情報が残ってる」と青ざめる回数が激減しました。状態管理全般の考え方はClaude Codeで状態管理を設計するガイドに、React側の進め方はClaude CodeでReact開発を進めるガイドにまとめてあるので、土台から整理したい人はそちらもどうぞ。

TypeScriptでストアを定義する

では実物です。管理画面フィルタ、カート、認証UI、モーダル、トーストをひとつのストアに入れた、最小の完成形を置きます。実務ではファイルを分けてもいいのですが、Claude Codeに頼むときは、まずこの形を作らせるとレビューが楽です。型があるので、入れてはいけない値を足そうとすると型エラーで気づけます。

import { create, type StateCreator } from "zustand";

export type OrderStatus = "all" | "paid" | "refunded" | "failed";
export type AuthUiStatus = "anonymous" | "checking" | "signedIn";
export type ModalId = "invite-user" | "cart-drawer" | "delete-order" | null;

export interface AdminFilters {
  keyword: string;
  status: OrderStatus;
  page: number;
  pageSize: number;
}

export interface CartLine {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export interface AuthUi {
  status: AuthUiStatus;
  loginDialogOpen: boolean;
}

export interface Toast {
  id: string;
  kind: "success" | "error" | "info";
  message: string;
}

export interface CommerceUiState {
  filters: AdminFilters;
  cart: CartLine[];
  auth: AuthUi;
  activeModal: ModalId;
  toasts: Toast[];
  setFilters: (patch: Partial<AdminFilters>) => void;
  resetFilters: () => void;
  addToCart: (line: Omit<CartLine, "quantity">) => void;
  removeFromCart: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  setAuthStatus: (status: AuthUiStatus) => void;
  setLoginDialogOpen: (open: boolean) => void;
  openModal: (modal: Exclude<ModalId, null>) => void;
  closeModal: () => void;
  pushToast: (toast: Omit<Toast, "id">) => void;
  dismissToast: (id: string) => void;
  checkoutTotal: () => number;
}

export const initialFilters: AdminFilters = {
  keyword: "",
  status: "all",
  page: 1,
  pageSize: 25,
};

// crypto.randomUUID が無い環境でも壊れないようにフォールバック
const createToastId = () =>
  globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);

export const createCommerceUiSlice: StateCreator<CommerceUiState> = (set, get) => ({
  filters: initialFilters,
  cart: [],
  auth: { status: "anonymous", loginDialogOpen: false },
  activeModal: null,
  toasts: [],

  // フィルタを変えたらページは必ず1へ戻す(古いページ番号で空表示になる事故を防ぐ)
  setFilters: (patch) =>
    set((state) => ({
      filters: {
        ...state.filters,
        ...patch,
        page: patch.page ?? 1,
      },
    })),

  resetFilters: () => set({ filters: initialFilters }),

  addToCart: (line) =>
    set((state) => {
      const current = state.cart.find((item) => item.id === line.id);
      if (!current) {
        return { cart: [...state.cart, { ...line, quantity: 1 }] };
      }

      return {
        cart: state.cart.map((item) =>
          item.id === line.id ? { ...item, quantity: item.quantity + 1 } : item,
        ),
      };
    }),

  removeFromCart: (id) =>
    set((state) => ({
      cart: state.cart.filter((item) => item.id !== id),
    })),

  // 数量が0以下になった行は自動で消す
  updateQuantity: (id, quantity) =>
    set((state) => ({
      cart: state.cart
        .map((item) =>
          item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item,
        )
        .filter((item) => item.quantity > 0),
    })),

  setAuthStatus: (status) =>
    set((state) => ({
      auth: { ...state.auth, status },
    })),

  setLoginDialogOpen: (open) =>
    set((state) => ({
      auth: { ...state.auth, loginDialogOpen: open },
    })),

  openModal: (modal) => set({ activeModal: modal }),
  closeModal: () => set({ activeModal: null }),

  pushToast: (toast) =>
    set((state) => ({
      toasts: [...state.toasts, { ...toast, id: createToastId() }],
    })),

  dismissToast: (id) =>
    set((state) => ({
      toasts: state.toasts.filter((toast) => toast.id !== id),
    })),

  checkoutTotal: () =>
    get().cart.reduce((total, item) => total + item.price * item.quantity, 0),
});

export const useCommerceUiStore = create<CommerceUiState>()(createCommerceUiSlice);

ここで地味に効いているのが、認証「そのもの」ではなく認証の「UI状態」だけを持っている点です。アクセストークンやメールアドレス、住所をストアに入れた瞬間、それらは画面のあちこちから読める状態になります。Claude Codeには「PII、つまり個人を特定できる情報はストアに入れない」と最初に言い切ってください。これだけで後始末がかなり減ります。

セレクタで再レンダリングを絞る

冒頭で僕がやらかした「トースト1件で画面全体がカクつく」事故。原因はここです。

Zustandのストアはフックなので、コンポーネントから直接読めます。ただし useCommerceUiStore() と書いてストア全体を購読すると、ストア内のどれか1つでも変わるたびに、そのコンポーネントが再描画されます。トーストが増えただけで、カートバッジもフィルタバーも全部巻き込まれる。

これを止めるのがセレクタ(selector)です。セレクタは「ストアの中から、自分が本当に必要な部分だけを選ぶ関数」です。値を1つだけ選ぶならそのまま渡せばよく、複数をオブジェクトでまとめて返すときは useShallow を挟みます。useShallow は浅い比較をして、中身が同じなら再描画をスキップしてくれます。

import { useShallow } from "zustand/react/shallow";
import {
  useCommerceUiStore,
  type CommerceUiState,
} from "./commerce-ui-store";

// カートの合計個数だけを選ぶ。cart 以外が変わっても再描画されない
export const selectCartCount = (state: CommerceUiState) =>
  state.cart.reduce((sum, item) => sum + item.quantity, 0);

export const selectCartTotal = (state: CommerceUiState) => state.checkoutTotal();

export function CartBadge() {
  const count = useCommerceUiStore(selectCartCount);

  return <button type="button">Cart ({count})</button>;
}

export function AdminFilterSummary() {
  // 複数の値をまとめて取るときは useShallow で浅い比較にする
  const { filters, setFilters, resetFilters } = useCommerceUiStore(
    useShallow((state) => ({
      filters: state.filters,
      setFilters: state.setFilters,
      resetFilters: state.resetFilters,
    })),
  );

  return (
    <form>
      <input
        value={filters.keyword}
        placeholder="Search orders"
        onChange={(event) => setFilters({ keyword: event.currentTarget.value })}
      />
      <select
        value={filters.status}
        onChange={(event) =>
          setFilters({ status: event.currentTarget.value as CommerceUiState["filters"]["status"] })
        }
      >
        <option value="all">All</option>
        <option value="paid">Paid</option>
        <option value="refunded">Refunded</option>
        <option value="failed">Failed</option>
      </select>
      <output>Page {filters.page}</output>
      <button type="button" onClick={resetFilters}>
        Reset
      </button>
    </form>
  );
}

落とし穴がもう一つあって、セレクタの中で毎回あたらしい配列やオブジェクトを作ると、useShallow を使っても「毎回ちがう参照」と判定されて再描画が止まりません。useShallow は浅い比較なので、中の要素が同じ参照かどうかを見ます。だから「必要な値を素直に選んで返す」のが基本で、整形や加工はコンポーネント側でやるか、useShallow で包むかのどちらかにします。UIが小さいうちはこの差が見えませんが、テーブルとサイドバーと通知が同居する管理画面では、ここの有無で体感速度がはっきり変わります。

persistはpartializeで保存対象を絞る

persist ミドルウェアは、リロードしても状態を残してくれる便利な仕組みです。でも、残す値を絞らないpersistは事故装置になります。

カートやフィルタは残ってうれしい。一方で、モーダルの開閉、トースト、認証のUI状態、処理中のrequestIdまで保存してしまうと、リロード後に謎のモーダルが開いていたり、消したはずのトーストが復活したりします。もっとまずいのは、何も考えずストア全体を保存して、トークンや個人情報がlocalStorageに平文で残るパターンです。

そこで partialize を使います。これは「保存する項目だけを抜き出す関数」です。下の例では保存対象を filterscart だけに絞り、サーバー側(SSR)では window が無いので、何もしない安全なstorageを返しています。公式のpersistミドルウェアのリファレンスに各オプションの説明があります。

import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
  createCommerceUiSlice,
  type CommerceUiState,
} from "./commerce-ui-store";

// SSR(サーバー側)では window が無いので、何もしないstorageを返す
const noopStorage = {
  getItem: () => null,
  setItem: () => undefined,
  removeItem: () => undefined,
};

type PersistedCommerceUiState = Pick<CommerceUiState, "filters" | "cart">;

export const usePersistedCommerceUiStore = create<CommerceUiState>()(
  persist(createCommerceUiSlice, {
    name: "commerce-ui-v1",
    version: 1,
    storage: createJSONStorage(() =>
      typeof window === "undefined" ? noopStorage : window.localStorage,
    ),
    // 保存していいのはフィルタとカートだけ。トークンや個人情報は絶対に含めない
    partialize: (state): PersistedCommerceUiState => ({
      filters: state.filters,
      cart: state.cart,
    }),
  }),
);

Next.jsやAstroのようにSSRがある環境では、もう一つ気をつけることがあります。サーバーで作ったHTMLは当然localStorageを読めないのでカート0件、ブラウザではlocalStorageから3件復元、という差が出ます。これがhydration(ハイドレーション)不一致で、コンソールに警告が出たり表示がちらついたりします。バッジや合計金額のように復元値で変わる表示は、マウント後に出すか、サーバーでは描画しない領域に切り分ける。これもClaude Codeへの指示に入れておくと安全です。

非同期と楽観更新で古いレスポンスに負けない

楽観更新(optimistic update)は、サーバーの成功を待たずに画面だけ先に変えてしまう手法です。フォロー、いいね、カートの数量変更のように、失敗率が低くて待ち時間を減らしたい操作に向きます。

ただ、ここに罠があります。非同期の処理が競合すると、先に送ったリクエストの古いレスポンスが、あとから返ってきて新しい状態を上書きすることがあるんです。連打したとき、タブを切り替えたとき、回線が遅いとき。これを防ぐ最低ラインが、requestIdを持つことと、失敗したときに戻す値を保存しておくことです。

import { create } from "zustand";

interface Profile {
  id: string;
  name: string;
  isFollowing: boolean;
}

interface ProfileState {
  profiles: Record<string, Profile>;
  followRequestIds: Record<string, string>;
  setProfile: (profile: Profile) => void;
  followOptimistically: (profileId: string) => Promise<void>;
}

// オブジェクトから特定キーを取り除いて新しいオブジェクトを返す
const removeKey = <T,>(record: Record<string, T>, key: string) => {
  const { [key]: _removed, ...rest } = record;
  return rest;
};

async function updateFollowOnServer(profileId: string, follow: boolean) {
  const response = await fetch(`/api/profiles/${profileId}/follow`, {
    method: follow ? "PUT" : "DELETE",
  });

  if (!response.ok) {
    throw new Error(`Follow update failed: ${response.status}`);
  }
}

export const useProfileStore = create<ProfileState>((set, get) => ({
  profiles: {},
  followRequestIds: {},

  setProfile: (profile) =>
    set((state) => ({
      profiles: { ...state.profiles, [profile.id]: profile },
    })),

  followOptimistically: async (profileId) => {
    const before = get().profiles[profileId];
    if (!before) {
      throw new Error("Profile not found");
    }

    // この操作を識別するID。あとで「自分が最新か」を確かめるのに使う
    const requestId = `${profileId}-${Date.now()}`;

    // まず画面を先に更新(楽観的に成功とみなす)
    set((state) => ({
      profiles: {
        ...state.profiles,
        [profileId]: { ...before, isFollowing: true },
      },
      followRequestIds: {
        ...state.followRequestIds,
        [profileId]: requestId,
      },
    }));

    try {
      await updateFollowOnServer(profileId, true);
    } catch (error) {
      set((state) => {
        // 自分より新しいリクエストが走っていたら、戻さずに譲る
        if (state.followRequestIds[profileId] !== requestId) return state;

        return {
          profiles: { ...state.profiles, [profileId]: before },
          followRequestIds: removeKey(state.followRequestIds, profileId),
        };
      });
      throw error;
    }

    set((state) => {
      if (state.followRequestIds[profileId] !== requestId) return state;

      return {
        followRequestIds: removeKey(state.followRequestIds, profileId),
      };
    });
  },
}));

Claude Codeにこの手の非同期を書かせるときは、「失敗時に何を戻すか」「同じIDへの連打を許すか」「戻したあとトーストを出すか」を必ず指定します。ここを曖昧にすると、見た目はきれいなasync/awaitが返ってきますが、実際のユーザー操作では二重送信や古いレスポンスにあっさり負けます。きれいさと正しさは別物です。

Redux・Contextとどう使い分けるか

「Zustandでいいのか、ReduxやContextか」とよく聞かれます。僕の使い分けはこうです。

Context APIは、テーマやロケールのように「ほとんど変わらない値」を配るには十分です。けれど、頻繁に変わる値をContextに入れると、そのContextを読むコンポーネントが軒並み再描画されます。再レンダリングを細かく制御したいなら、Contextは向きません。

Reduxは、アクションとリデューサーで更新の流れが厳格に追えるのが強みです。大人数のチームで「誰がどう状態を変えたか」を完全に追跡したい、タイムトラベルデバッグを使いたい、という場面では今でも有力です。代償はボイラープレートの多さです。

Zustandはその中間で、ボイラープレートを最小にしつつ、セレクタで再レンダリングも絞れます。小〜中規模で「軽く、でも速度は妥協したくない」ときの第一候補です。下の表が僕のざっくり基準です。

観点Context APIRedux (Toolkit)Zustand
書く量少ない多い少ない
再レンダリング制御弱いセレクタで可セレクタで強い
更新の追跡しやすさ低い高い(履歴・devtools)中(devtools可)
向く規模小(不変に近い値)中〜大・大人数小〜中
学習コスト低い高い低い

なお、サーバーから取ってくるデータはどれを選んでも別腹です。一覧の取得・キャッシュ・再取得は専用ライブラリに任せ、Zustandには画面のUI状態だけ置く。粒度の細かいatom的な管理が欲しい場面もあって、それは別記事で触れています。状態管理の全体像から決めたい人は状態管理の設計ガイドを先に読むと、ここの判断が速くなります。

コピペで動く:Vitestでストアをテストする

Zustandのストアは、Reactを描画しなくてもテストできます。これが軽量ライブラリの地味な利点で、純粋なオブジェクトに近いから、ロジックだけを高速に検証できます。コツは1つだけ、各テストの前に初期状態へ戻すこと。これを忘れると前のテストのカートが残って、原因不明の失敗に悩みます。

下のテストはそのままプロジェクトに置けば動きます(commerce-ui-store.ts は上のストア定義)。

import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";

// 起動直後の初期状態を控えておく
const initialState = useCommerceUiStore.getInitialState();

beforeEach(() => {
  // 各テストの前に必ずストアをまっさらに戻す(第2引数 true で全置換)
  useCommerceUiStore.setState(initialState, true);
});

describe("commerce ui store", () => {
  it("同じ商品を2回入れたら数量が2になり、合計が計算される", () => {
    const store = useCommerceUiStore.getState();

    store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
    store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });

    expect(useCommerceUiStore.getState().cart[0]?.quantity).toBe(2);
    expect(useCommerceUiStore.getState().checkoutTotal()).toBe(2400);
  });

  it("フィルタを変えたらページが1に戻る", () => {
    useCommerceUiStore.getState().setFilters({ page: 4 });
    useCommerceUiStore.getState().setFilters({ keyword: "refund" });

    expect(useCommerceUiStore.getState().filters).toMatchObject({
      keyword: "refund",
      page: 1,
    });
  });

  it("認証UIとトーストが意図どおり保持される", () => {
    useCommerceUiStore.getState().setAuthStatus("signedIn");
    useCommerceUiStore.getState().pushToast({
      kind: "success",
      message: "Saved",
    });

    expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
    expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
  });
});

テストの良いところは、「コードが動くか」だけでなく「設計の約束が守られているか」を固定できる点です。フィルタ変更でページが1に戻る、数量0で行が消える、楽観更新の失敗時に戻る。こういう仕様を先にテスト名に書いておくと、あとでClaude Codeに直させても挙動がブレません。実装とレビューは別のメッセージで頼み、レビュー時は「このdiffのZustand部分だけを批判的に見て、ストアに入れるべきでない値・広すぎるセレクタ・persistの漏れを指摘して」と観点を固定すると、自分が書いたコードを甘く肯定する癖を抑えられます。

よくある質問

Q. ストアはアプリに1つ?それとも複数に分ける? A. 小規模なら1つで十分です。機能が増えてきたら、カート用・管理画面用のように関心ごとに分けると見通しが良くなります。Zustandはストアを複数作れるので、巨大な1つを無理に維持しなくて大丈夫です。

Q. useShallow はいつ必要ですか? A. セレクタが複数の値をオブジェクトや配列でまとめて返すときです。単一の値(数値や文字列)を返すだけなら不要です。複数返すのに付けないと、毎回ちがう参照と判定されて再描画が止まりません。

Q. persistしたデータの形を変えたいときは? A. version を上げて migrate で旧データを変換します。name(保存キー)を変えると別物として扱われ古いデータが残るので、形が変わったら version で明示的に移行するのが安全です。

Q. サーバーから取ったデータもZustandに入れていい? A. 基本は入れません。キャッシュや再取得が必要なサーバーデータは専用ライブラリに任せ、Zustandには画面のUI状態だけを置きます。混ぜると、stale判定やローディング管理を自前で抱えることになります。

Q. ReduxからZustandへ移行する価値はある? A. ボイラープレートを減らしたい、再レンダリングを軽くしたい、という動機なら価値があります。逆に厳格な更新履歴やタイムトラベルデバッグに依存しているなら、Reduxを残す判断も妥当です。

実際に試した結果

冒頭の「トースト1件で画面がカクつく」事故のあと、僕がまずやったのは新機能の追加ではなく、状態を入れる前に表を書くことでした。「残していい値」と「残すと危ない値」を分けた表です。

その表を起点に、ストアにはUI状態だけを置き、コンポーネントは全部セレクタ経由で読み、persistpartializefilterscart だけに絞る。これを徹底したら、通知を足してもテーブルは静かなままになり、localStorageから個人情報が消えました。

この記事のコードは、2026年6月7日にZustand公式ドキュメントの createpersistuseShallow とテストの考え方を確認し、TypeScriptとしてそのままコピーできる形に整えています。実案件で何度か試して感じるのは、設計の差はコードの短さではなく「境界の明確さ」に出る、ということです。最初の10分で表を書くほうが、あとで半日かけてlocalStorageを掃除するより、ずっと速いです。

状態管理の判断基準をチームでそろえたい、既存の管理画面を題材に棚卸ししたい、という場合は研修・導入相談でも一緒に整理できます。まずは上のストアとテストをそのまま動かして、自分の画面に「入れない値の表」を1枚足すところから始めてみてください。

#Claude Code #Zustand #React #状態管理 #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。