メインコンテンツへスキップ
kt-tech.blog

【Next.js】 Zustandを用いた状態管理について

技術5分で読めます

この記事でわかること

  • 同じカウンターを useState + localStorage と Zustand の両方で書いたときの差
  • persist ミドルウェアで、useEffect を書かずに状態を永続化する方法
  • Next.js で使うときに踏む localStorage と hydration の問題

React の useState / useEffect を書いたことがあること。Next.js の App Router を使う場合は Client Component と Server Component の違いを知っていると読みやすいです。

概要

この記事では、Zustand とは何で、用いると何が良いのかを自分の最近得た知識として残しておきます。

Zustandとは

Zustand は、主にReactで利用される小さくてシンプルな状態管理ライブラリです。

Zustand を利用することによって、状態管理に関連するコードの複雑さを大幅に解消できます。

また、HookベースのAPIを提供しているため、React Hooksに慣れている開発者にとっては直感的に使うことが可能です。

リロード可能なカウンターアプリを作成し、「localStorageuseState」で記述した場合と、「Zustand」のみで記述した場合の記述の違いを見ていきます。

useState + localStorage

TypeScript
import React, { useState, useEffect } from 'react';

export function App() {
  const [count, setCount] = useState(() => {
    const savedCount = localStorage.getItem('count');
    return savedCount ? parseInt(savedCount, 10) : 0;
  });

  useEffect(() => {
    localStorage.setItem('count', count.toString());
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}

export default App;

Zustand

使用感はReduxに近いものだと感じました。

わざわざuseEffectを書くことがない点や、状態値と更新用関数をまとめて管理できるのも運用が楽だしコード量が増えないので良いなと思いました。

TypeScript
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

const useCounterStore = create(
  persist(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 })),
    }),
    { name: 'counter', storage: createJSONStorage(() => localStorage) }
  )
);

export function App() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
}

export default App;
ポイント
persist の保存先の指定方法は、執筆後に変わりました。執筆時の Zustand v4 初期は getStorage: () => localStorage でしたが、v4 途中で非推奨になり、v5 では削除されました。現在は上のコードのように storage: createJSONStorage(() => localStorage) と書きます(本記事のコードは新しい書き方に差し替え済みです)。
注意
Next.js で使うときの注意。上の useState 版のコードは、そのまま Server Component や SSR を通る場所に置くと localStorage is not defined で落ちます。ファイル先頭に 'use client' を付けてください。Zustand の persist も同様で、それに加えてサーバとクライアントで初期値が違うことによる hydration エラーが起きます(サーバは 0、クライアントは保存値)。マウント後に描画を切り替えるか、skipHydration を使って手動で rehydrate します。

おわりに

useStatelocalStorageを使った煩雑な状態管理を統一することができるZustand をこれからは選択肢として覚えておこうと思いました。

TypeScriptだと型周りちゃんとしないとなという感じです。

なお、すでに React Context で状態管理しているプロジェクトを Zustand に寄せる話は、別記事の React Context → zustand 移行ガイド で書いています。

参考リンク

更新履歴

  1. persist の保存先指定を、現行の storage: createJSONStorage(() => localStorage) に差し替え(旧記法の getStorage は v5 で削除済みのためコピペで動かない)。Next.js での localStorage と hydration の注意を追加。内容のない「本文」見出しを削除。裸 URL をタイトル付きリンクに変更し、公式ドキュメントを追加。関連記事へのリンクを追加。
  2. OGP Description の修正