デザインについての学習メモブログ

Next.js入門 #12 実践編  状態管理入門 — ZustandとTanStack Query

記事内に広告が含まれています。

Next.js入門 #12 実践編  状態管理入門 — ZustandとTanStack Query

ここまでの実装では、モーダルの開閉状態をuseStateで管理したり(#11)、記事一覧をServer Componentで直接取得したり(#9)してきました。

小さな機能であればこれで十分ですが、アプリが育ってくると次のような悩みが出てきます。

  • モーダルの開閉状態を、離れた場所にある複数のコンポーネントから参照・変更したい
  • 記事一覧を「サーバーから取ってきたデータ」として、キャッシュしたり再取得したりしたい
  • 「読み込み中」「エラー」「再取得中」といった状態を、いちいち自分で管理したくない

これらを解決するのが、今回扱うZustand(グローバルなUI状態の管理)とTanStack Query(サーバーデータのキャッシュ管理)です。

この記事は#3のClient Componentの知識が前提です。

なぜ「Redux」でも「Recoil」でもなく、この2つなのか

状態管理ライブラリには、

  • Redux
  • Recoil
  • Jotai
  • MobX

など複数の選択肢があります。

この記事でZustandとTanStack Queryを選んだ理由を先に説明しておきます。

Reduxを選ばなかった理由

機能面で劣っているわけではなく、単純に「学習コストとコード量」の問題です。

Redux(Redux Toolkit)はバンドルサイズが約35KBあるのに対し、Zustandは2.1KB程度と16倍近い差があります。

それ以上に、storeのセットアップ・Providerでのラップ・action/reducerの定義など、状態を1つ扱うだけでも構成要素が多く、このシリーズように複数のテーマを1本のアプリで扱うシリーズでは説明に紙幅を割きすぎてしまいます。

なお、開発者ツールでの状態追跡や、予測可能な状態管理が重視されるエンタープライズ向けアプリケーションでは、Reduxは今でも有力な選択肢です。

Recoilを選ばなかった理由

これは明確な理由があります。Recoilの公式GitHubリポジトリは、2025年1月1日にアーカイブされており、開発が事実上停止しています。

2026年に公開する教材で、メンテナンスの止まったライブラリを軸に据えるのは避けるべきだと判断しました。

Zustandを選んだ理由

2025年のReact状態管理サーベイでは、Zustandの利用率が2023年の28%から2025年には50%まで伸びており、2年でほぼ倍増しています。

ZustandとReduxを合わせると、状態管理ライブラリ全体のダウンロード数の6割以上を占めており、現時点でのデファクトスタンダードに近い位置づけです。

技術的にも、Providerでアプリ全体をラップする必要がなく、create()で作った関数をそのままフックとして呼び出すだけで使えるという手軽さが、この記事で扱う「モーダルの開閉状態」程度の用途に見合っています。

TanStack Queryを組み合わせる理由

ReduxもRecoilも、あくまで「クライアント側の状態」を管理するためのものです。

「サーバーから取得したデータをどうキャッシュ・再取得するか」という関心事は別物で、自分で実装しようとするとisLoadingisErrorのフラグ管理だけでも一苦労です。

TanStack Queryはその部分に特化しているため、後述する「UI状態はZustand、サーバーデータはTanStack Query」という役割分担が、今のReactエコシステムでは素直な組み合わせになります。

この記事のゴール

  • 「UI状態」と「サーバーデータ」を区別して考えられるようになる
  • Zustandでモーダルの開閉状態など、グローバルなUI状態を管理する
  • TanStack Queryで記事一覧の取得・キャッシュ・再取得を管理する

2種類の「状態」を区別する

状態管理を学ぶ上で最初につまずきやすいのが、「状態」と一括りにされているものが、実は性質の異なる2種類に分かれるという点です。

種類具体例特徴
UI状態(クライアント状態)モーダルの開閉、サイドバーの展開/折りたたみ、選択中のタブサーバーには存在しない、ブラウザ内だけの状態
サーバーデータ(サーバー状態)記事一覧、ユーザー情報サーバーが「正」のデータであり、クライアントはそのコピー(キャッシュ)を持っているだけ

この2つを同じuseStateで扱おうとすると、「いつ再取得するか」「他のタブと同期するか」「キャッシュはどれくらい保持するか」といった問題を全部自分で実装することになります。

Zustandは前者(UI状態)、TanStack Queryは後者(サーバーデータ)を担当する、という役割分担で考えると理解しやすくなります。

Zustandでグローバルなモーダル状態を管理する

#11で作った投稿モーダルは、NewPostDialogコンポーネント自身がuseStateで開閉状態を持っていました。

この場合、例えばヘッダーの中にある別のボタンから「モーダルを開く」ことができません。

Zustandを使って、この状態をコンポーネントの外に出してみましょう。

インストール

Bash
npm install zustand

ストアを作成する

TypeScript
// stores/use-post-dialog-store.ts
import { create } from "zustand";

type PostDialogState = {
  isOpen: boolean;
  open: () => void;
  close: () => void;
};

export const usePostDialogStore = create<PostDialogState>((set) => ({
  isOpen: false,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}));

createに渡す関数の中で、状態(isOpen)と、それを更新する関数(open / close)をまとめて定義します。

Reduxのようなdispatchaction typeは不要で、setを呼ぶだけで状態が更新されます。

ストアを使う

どのコンポーネントからでも、フックとして呼び出すだけで同じ状態にアクセスできます。

TSX
// app/posts/_components/new-post-dialog.tsx
"use client";

import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { usePostDialogStore } from "@/stores/use-post-dialog-store";
// ...フォームの中身は#11と同様

export default function NewPostDialog() {
  const { isOpen, close } = usePostDialogStore();

  return (
    <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
    {/* ここにあった <DialogTrigger render={<Button>...</Button>} /> は削除する */}
      <DialogContent>
        <DialogHeader>
          <DialogTitle>新しい記事</DialogTitle>
        </DialogHeader>
        {/* フォームは省略 */}
      </DialogContent>
    </Dialog>
  );
}

見逃しやすいポイント

DialogTriggerを消し忘れると、Header側のボタンと合わせて「新しい記事を書く」ボタンが画面に2つ表示されてしまいます。

片方は動くのにもう片方は反応しない、という紛らわしい状態になるので、new-post-dialog.tsxを開いてDialogTriggerが残っていないか必ず確認してください。

TSX
// components/header.tsx
"use client";

import { usePostDialogStore } from "@/stores/use-post-dialog-store";
import NewPostDialog from "@/app/posts/_components/new-post-dialog";

export default function Header({ authSlot }: { authSlot: React.ReactNode }) {
  const open = usePostDialogStore((state) => state.open);

  return (
    <header className="flex items-center justify-between p-4">
      <h1 className="text-lg font-bold">ミニブログ</h1>
      <div className="flex items-center gap-3">
        <button
          onClick={open}
          className="rounded-md bg-black px-4 py-2 text-sm text-white"
        >
          新しい記事を書く
        </button>
        {authSlot}
      </div>
      <NewPostDialog />
    </header>
  );
}

AuthButtons#10で作った、async functionで書かれたServer Component)を、Client ComponentであるHeaderから直接importすることはできません。

Server ComponentはClient Componentのファイルの中に直接埋め込めない、というApp Routerのルールがあるためです。

代わりに、authSlotというpropsReact.ReactNode型の「穴」)として受け取るようにし、実際にどのコンポーネントをそこに描画するかは、呼び出し元であるServer Component側(app/layout.tsx)に決めてもらう形にします。

これは、Client ComponentからServer Componentを扱いたいときの定番のパターンです。

NewPostDialog#11"use client"を付けたClient Componentなので、こちらは今までどおり直接importして問題ありません。

続けて、呼び出し元のapp/layout.tsx側で、AuthButtonsauthSlotとして渡します。

Headerをレイアウトに組み込む

app/layout.tsx側は、上のコードのとおりHeaderを呼び出す形にしてください。

「ログイン状態の表示」と「新規投稿ボタン」はどちらもHeaderコンポーネント側にまとまりました。

以降、ヘッダーの見た目を調整したいときは、components/header.tsxを触れば済むようになります。

TSX
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import AuthButtons from "@/components/auth-buttons";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
import { Toaster } from "@/components/ui/sonner";
import Header from "@/components/header";

const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja" className={cn("font-sans", geist.variable)}>
      <body className="min-h-full flex flex-col">
        <Header authSlot={<AuthButtons />} />
        <main className="flex-1">{children}</main>
        <Toaster />
      </body>
    </html>
  );
}

これで、app/layout.tsx側はHeaderを呼び出すだけのシンプルな状態になり、「ログイン状態の表示」と「新規投稿ボタン」はどちらもHeaderコンポーネント側にまとまりました。

以降、ヘッダーの見た目を調整したいときは、components/header.tsxを触れば済むようになります。

TanStack Queryで記事一覧を管理する

続いて、記事一覧をクライアント側で取得・キャッシュする方法を見ていきます。

検索機能やフィルタリングなど、ページ全体をリロードせずにデータを切り替えたい場面で威力を発揮します。

インストール

Bash
npm install @tanstack/react-query

QueryClientProviderをセットアップする

TSX
// app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
TSX
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import AuthButtons from "@/components/auth-buttons";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
import { Toaster } from "@/components/ui/sonner";
import Header from "@/components/header";
import { Providers } from "./providers";

const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja" className={cn("font-sans", geist.variable)}>
      <body className="min-h-full flex flex-col">
        <Header authSlot={<AuthButtons />} />
        <Providers>
          <main className="flex-1">{children}</main>
        </Providers>
        <Toaster />
      </body>
    </html>
  );
}

useStateQueryClientのインスタンスを保持しているのは、レンダリングのたびに新しいインスタンスが作られてキャッシュが失われるのを防ぐためです。

データ取得用のAPIを用意する

TanStack QueryはServer Actionsとの相性が微妙な部分があるため、記事一覧の取得は素直にRoute Handlerとして用意します。

TSX
// app/api/posts/route.ts
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const keyword = searchParams.get("keyword") ?? "";

  const posts = await prisma.post.findMany({
    where: {
      title: { contains: keyword, mode: "insensitive" },
    },
    orderBy: { createdAt: "desc" },
  });

  return NextResponse.json(posts);
}

useQueryでデータを取得する

TSX
// app/posts/_components/post-list.tsx
"use client";

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";

type Post = {
  id: number;
  title: string;
  content: string;
};

async function fetchPosts(keyword: string): Promise<Post[]> {
  const res = await fetch(`/api/posts?keyword=${keyword}`);
  if (!res.ok) throw new Error("記事の取得に失敗しました");
  return res.json();
}

export default function PostList() {
  const [keyword, setKeyword] = useState("");

  const { data: posts, isLoading, isError } = useQuery({
    queryKey: ["posts", keyword],
    queryFn: () => fetchPosts(keyword),
  });

  return (
    <div className="space-y-4">
      <Input
        placeholder="記事を検索"
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
      />

      {isLoading && <p>読み込み中...</p>}
      {isError && <p>エラーが発生しました</p>}

      <ul className="space-y-2">
        {posts?.map((post) => (
          <li key={post.id}>
            <h3 className="font-bold">{post.title}</h3>
            <p className="text-sm text-muted-foreground">{post.content}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

queryKey["posts", keyword]を指定することで、キーワードが変わるたびに自動的に再取得され、かつ一度取得した結果はキーワードごとにキャッシュされます。

同じキーワードで再検索した場合、キャッシュがあれば通信すら発生しません。

isLoadingisErrorも自前でuseStateを用意する必要はなく、TanStack Queryが自動的に管理してくれます。

投稿後にキャッシュを更新する(useMutation)

記事を新規作成した後、一覧のキャッシュを最新の状態に更新したい場合はuseMutationを使います。

ここでは、新しく/api/postsにPOSTエンドポイントを作るのではなく、#9から使っているcreatePost(Server Action)を、そのままuseMutationmutationFnとして使います。

TSX
// app/posts/_components/new-post-dialog.tsx
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { usePostDialogStore } from "@/stores/use-post-dialog-store";
import { createPost } from "../actions";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";

export default function NewPostDialog() {
  const { isOpen, close } = usePostDialogStore();
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      // "posts"から始まるqueryKeyをすべて無効化し、再取得させる
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      toast.success("記事を投稿しました");
      close();
    },
    onError: () => {
      toast.error("投稿に失敗しました");
    },
  });

  return (
    <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>新しい記事</DialogTitle>
        </DialogHeader>
        <form
          action={(formData) => mutation.mutate(formData)}
          className="space-y-4"
        >
          <div className="space-y-2">
            <Label htmlFor="title">タイトル</Label>
            <Input
              id="title"
              name="title"
              placeholder="記事のタイトル"
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="content">本文</Label>
            <Textarea id="content" name="content" rows={6} required />
          </div>
          <Button
            type="submit"
            className="w-full"
            disabled={mutation.isPending}
          >
            投稿する
          </Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

<form action={...}>には、通常はServer Actionを直接渡しますが、ここでは(formData) => mutation.mutate(formData)という薄いラッパー関数を渡しています。

こうすることで、フォーム送信のタイミングをmutation.mutate()のトリガーとして使いつつ、実際のサーバー側の処理は変わらずcreatePost(Server Action)が担う、という構成になります。

mutation.isPendingを使えば、送信中はボタンを無効化する、といった状態管理もTanStack Query側に任せられます。

invalidateQueriesを呼ぶと、該当するqueryKeyを持つすべてのクエリが「古い」とマークされ、画面に表示されているものは自動的に再取得されます。

「投稿したら一覧を更新する」という処理を、手動でstateを書き換えることなく実現できます。

Zustand と TanStack Query の役割分担まとめ

ZustandTanStack Query
扱うものUI状態(モーダル、フィルターのON/OFFなど)サーバーデータ(記事一覧、ユーザー情報など)
データの出どころフロントエンドだけで完結サーバー(DB)が正
主な機能シンプルなグローバルステートキャッシュ・再取得・ローディング/エラー状態の自動管理

「このデータはサーバーに存在するか?」と自問して、存在するならTanStack Query、存在しないならZustand、と考えると迷いにくくなります。

Zustandのサンプルイメージ
TanStack Queryのサンプルイメージ

まとめ

この記事では、以下を実装しました。

  • Zustandで、コンポーネントをまたいだモーダルの開閉状態を管理
  • TanStack Queryで、検索キーワードに応じた記事一覧の取得とキャッシュを実装
  • 投稿後にinvalidateQueriesでキャッシュを最新化する流れを構築

UI状態とサーバーデータを明確に分けて考えることで、それぞれに適したツールでシンプルに実装できることが分かったと思います。

次回の#13 ログインユーザーだけに機能を出す — 認証×DBを繋げるでは、#9のDBと#10の認証を組み合わせ、「自分の投稿だけ編集できる」といった実際のWebアプリに欠かせないアクセス制御を実装していきます。