🐣

Next.js 13のApp Routerでの状態管理

2023/10/14に公開

背景

Next.js 13のApp RouterではServer ComponentsとClient Componentsが登場し、従来利用していた useState() 等のフック関数はClient Componentsでのみ利用可能になりました。そのため、Client Componentsで管理している状態を変更したときに、別のClient Components(特に親子関係でないもの)やServer Componentsにどう状態を受け渡すか悩んだので実際に試してみました。

今回利用したフレームワーク/ライブラリー

  • Next.js: 13.5.4
  • Zustand: 4.4.3

試しに作ってみたもの

  • AddボタンとResetボタンをClient Componentsとして作成しています
  • その下のClient Component3にはボタン押下回数を表示してあり、Addボタン、Resetボタンと直接状態のやりとりはしておらず、Zustandを介して状態を共有しています
  • 左側のServer Component1にも同じようにボタン押下回数を表示しています。これは今回はクエリーパラメーターを介して押下回数を反映させていますが、できれば見直したいところです(他の方法がわからなかったのでこうしただけ)

コード

https://github.com/shoji9x9/zustand-study

そもそもClient/Server Componentsって?

あまり正確に理解できている自信はないですが・・・

  • ブラウザでJavaScriptを動かしたいものは(ブラウザ特有の技術を使いたいものは) Client Components
  • そうでないものは Server Components

と理解しています。そのため、制御コンポーネント(useState() で値を管理していたもの)はClient Componentsになります。他にもonClick属性を設定するもの、ローカルストレージを利用するものもClient Componentsになり、ファイルの冒頭に "use client"; と書く必要があります。

面倒臭い!全部Client Componentsしちゃえばいいんじゃ?

それでも実現はできますが、Next.jsはデフォルトでは全てServer Componentsにしようとするくらいなので、Server Componentsにはアドバンテージがあります。以下公式から引用です(一部のみ)。

  • データ フェッチ: サーバー コンポーネントを使用すると、データ フェッチをデータ ソースに近いサーバーに移動できます。これにより、レンダリングに必要なデータのフェッチにかかる時間と、クライアントが行う必要があるリクエストの量が削減され、パフォーマンスが向上します。
  • セキュリティ: サーバー コンポーネントを使用すると、トークンや API キーなどの機密データとロジックを、クライアントに公開するリスクなしにサーバー上に保持できます。
  • キャッシュ: サーバー上でレンダリングすることで、結果をキャッシュし、後続のリクエストや複数のユーザー間で再利用できます。これにより、各リクエストで実行されるレンダリングとデータのフェッチの量が減り、パフォーマンスが向上し、コストが削減されます。

詳細は以下を参照下さい。

https://nextjs.org/docs/app/building-your-application/rendering

じゃあ、状態共有のベストプラクティスを教えてくれ!

自分もそれが知りたかったのですが、どうもまだ確立されていなさそうな雰囲気を感じました。ということでいくつか情報収集しました。

(Next13 /app) React Server Components 時代の状態管理術【前編】

おそらくですが、Client Componentsで変更した状態をServer Componentsに渡すときはURLを利用しているのだと思います。他にも nrstate というライブラリーを利用する方法もあるそうです。後編をお待ちしております!

Did NextJS 13 Break State Management?

こちらでは Zustand が紹介されていました。

https://www.youtube.com/watch?v=OpMAH2hzKi8

commerce

こちらはVercelがNext.jsで作ったEコマースのデモサイトのようで、App Routerで書き直されており、React Server Componentsに最適化された設計になっている とのことだったので見てみました。

こちらではCookieを利用し状態を管理しています。状態は主にServer Componentsで管理している印象で useState() はモーダルの開閉などくらいにしか利用されていませんでした。例えば商品をカートに追加するとカート右上の個数が増えますが、カートはServer Componentsとして実装されています。

ただ、このコードでは Server Actions が利用されていますが、Server ActionsはまだStableではありません。一方でServer Actionsを利用しないとCookieで状態管理するのは難しそうな印象を受けました。cookieswrite outgoing request cookies in a Server Action or Route Handler. と書かれており、Client ComponentsにCookieを渡そうとするとServer Actionが必要そうに感じたためです(ちなみにRoute HandlerはREST APIです)。

そうすると直近仕事で使うにはServer Actionsは少しリスキー -> Cookieを利用した状態管理も難しそうと感じています。

で?

長くなりましたが、以上より今回は次の方針にすることにしました。

  • Zustand を利用し状態管理する
  • Server Componentsに状態を渡す際にはURLを利用する

Zustandの使い方

以下のようなファイルを作成します。

Counter.ts
import { create } from "zustand";

export const useCounterStore = create<{
  count: number;
  increment: () => void;
  reset: () => void;
}>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}));

Client Componentsでは以下のように利用します。これで引数を利用しなくてもClient Components間で状態を共有できます。非常に簡単ですね!

AddButton.tsx
export function AddButton() {
  const count = useCounterStore((state) => state.count);
  console.log(count);
  
  const onClick = () => {
    useCounterStore.getState().increment();
  };

  return (
    <button
      onClick={onClick}
    >
      Add
    </button>
  );
}

Server Componentsで最新の状態を利用する

Server Componentsで初期状態を利用するだけであれば、useCounterStore.getState().count と記述するだけでいいです。ただ、これだとClient Componentsで変更した状態を利用することはできません。そのためURLを利用し状態を渡すことにしました。具体的にはAddButton.tsxにボタン押下時にクエリーパラメーターをインクリメントする処理を追加しました。

AddButton.tsx
export function AddButton() {
  const count = useCounterStore((state) => state.count);
  const router = useRouter();
  const pathname = usePathname();
  
  const onClick = () => {
    useCounterStore.getState().increment();
    router.replace(`${pathname}/?count=${count + 1}`);  // この処理を追加
  };

  return (
    <button
      onClick={onClick}
    >
      Add
    </button>
  );
}

そのクエリーパラメーターをServer Components(SideMenu)に渡し描画しています。これで実現したいことが一通り実現できました!

page.tsx
export default function Home({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  const count = Number(searchParams["count"]) || 0;
  return (
    <SideMenu count={count} />
  );
}

次の活動計画

一応実装できたものの、現在の実装では状態の数が多くなるとその分URLも長くなってしまいます。そのため次は nrstate を試してみるつもりです。他にも「こういう方法があるんじゃない?」みたいなアドバイスがあれば是非コメントで教えて下さい!(自分はNext.js勉強し始めたばかりで全然わかっていない人なので)

追記

たまたまタイムリーに見たスライドにもベストプラクティスはまだないと書かれていました。

https://speakerdeck.com/uhyo/app-routershi-dai-nodetaqu-de-akitekutiya?slide=54

Discussion