9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Zustandを使って気づいた、もっと早く知りたかった7つのこと

9
Posted at

image.png

はじめに

Reactアプリが大きくなってくると、「prop drilling」という問題に直面します。コンポーネントツリーの深い場所にあるコンポーネントにstateを渡すために、3〜4層を経由してpropsをバケツリレーしていく、あの問題です。よく使われる解決策は useContext ですが、Contextにはパフォーマンス面で大きな欠点があります。stateが変わるたびに、そのstateを使っていない子コンポーネントまで含めて、すべてが再レンダリングされてしまうのです。

たとえば、サイドバー・ヘッダー・データテーブルが同じContextを参照しているダッシュボードを想像してください。ヘッダーの小さな通知バッジを更新しただけで、サイドバーもデータテーブルも一緒に再レンダリングされます。複雑なアプリでは、特にローエンドのデバイスでこれが明らかなカクつきの原因になります。

Zustandはこの2つの問題をまとめて解決します。Providerで囲む必要のないグローバルstateと、変更されたstateを実際に使っているコンポーネントだけを再レンダリングする仕組みを提供します。

npm install zustand

基本的な使い方

import { create } from 'zustand'

const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset:     () => set({ count: 0 }),
}))

コンポーネントで使う場合:

function Counter() {
  const count     = useCounterStore((s) => s.count)
  const increment = useCounterStore((s) => s.increment)

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+1</button>
    </div>
  )
}

まず覚えておくべき3つのポイント:

説明
create storeを作成する。関数を受け取り、カスタムhookを返す
set stateを更新する。現在のstateにマージされる(上書きではない)
selector hookに渡して、必要なstateだけを選択するための関数

ポイント1: セレクタで不要な再レンダリングを防ぐ

useContext との最大の違いがここです。必要なstateだけを選択することで、他のstateが変わったときにコンポーネントが再レンダリングされなくなります

// 良くない例: store全体を取得 → 何かが変わるたびに再レンダリング
const store = useUserStore()

// 良い例: usernameが変わったときだけ再レンダリング
const username = useUserStore((s) => s.username)

複数の値を同時に取得したいとき、こう書きたくなるかもしれません:

// 注意: レンダリングのたびに新しいオブジェクトを生成するため
// Zustandがstateが常に変わったと判断してしまい、再レンダリングが続く
const { username, email } = useUserStore((s) => ({ username: s.username, email: s.email }))

問題はここにあります。セレクタが実行されるたびに、usernameemail も変わっていないのに新しいオブジェクトが返されます。Zustandは === で比較するため、参照が異なる2つのオブジェクトは中身が同じでも「変わった」とみなされます。useShallow を使えば、参照ではなくキーごとに比較できます:

import { useShallow } from 'zustand/react/shallow'

// useShallowはキーごとに比較するため、usernameかemailが実際に変わったときだけ再レンダリング
const { username, email } = useUserStore(
  useShallow((s) => ({ username: s.username, email: s.email }))
)

ポイント2: 非同期アクションでAPIを呼び出す

set は通常の非同期関数の中でそのまま使えます。ミドルウェアや追加の設定は不要です:

const useUserStore = create((set) => ({
  user: null,
  loading: false,
  error: null,

  fetchUser: async (id) => {
    set({ loading: true, error: null })
    try {
      const data = await fetch(`/api/users/${id}`).then((r) => r.json())
      set({ user: data, loading: false })
    } catch (err) {
      set({ error: err.message, loading: false })
    }
  },
}))

コンポーネントで使う場合:

function UserProfile({ id }) {
  const user      = useUserStore((s) => s.user)
  const loading   = useUserStore((s) => s.loading)
  const fetchUser = useUserStore((s) => s.fetchUser)

  // fetchUserはstoreで定義されているため安定した参照を持つ
  // 依存配列に入れてもESLintのルールに従いつつ、無限ループにもならない
  useEffect(() => { fetchUser(id) }, [id, fetchUser])

  if (loading) return <p>読み込み中...</p>
  return <p>{user?.name}</p>
}

ポイント3: persistでlocalStorageにstateを保存する

persist ミドルウェアを使えば、ページをリロードしてもstateが消えません。テーマ、言語設定、カート、ユーザー設定などに便利です。

セキュリティに関する注意: 認証トークンをlocalStorageに保存するのは避けましょう。XSSによる盗難リスクがあります。トークンはサーバー側でhttpOnly Cookieを使って管理するのが適切です。

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light',
      language: 'ja',
      setTheme:    (theme)    => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'app-settings', // localStorageのキー名
    }
  )
)

特定のフィールドだけを保存したい場合は、partialize を使います:

persist(
  (set) => ({
    theme: 'light',
    tempData: null,
    // 他のstate
  }),
  {
    name: 'app-settings',
    partialize: (state) => ({ theme: state.theme }), // themeだけ保存、tempDataは除外
  }
)

ポイント4: 大規模なstoreの整理にはスライスパターン

アプリが大きくなってきたら、すべてを1つのstoreに詰め込むのはやめましょう。複数の「スライス」に分割してから合体させます:

// store/userSlice.js
export const createUserSlice = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  logout:  ()     => set({ user: null }),
})

// store/cartSlice.js
export const createCartSlice = (set) => ({
  items: [],
  addItem:    (item) => set((s) => ({ items: [...s.items, item] })),
  removeItem: (id)   => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
  clearCart:  ()     => set({ items: [] }),
})

// store/index.js: すべてをまとめる
const useStore = create((set, get) => ({
  ...createUserSlice(set, get),
  ...createCartSlice(set, get),
}))

export default useStore

合体後は、すべてのスライスのstateとactionが useStore に集約されます。どのスライスに属しているかを意識せず、通常通りセレクタで使えます:

function Header() {
  // userSliceのstate
  const user   = useStore((s) => s.user)
  const logout = useStore((s) => s.logout)

  // cartSliceのstate: 同じuseStore、異なるセレクタ
  const itemCount = useStore((s) => s.items.length)

  return (
    <header>
      <span>ようこそ、{user?.name}さん</span>
      <span>カート: {itemCount}</span>
      <button onClick={logout}>ログアウト</button>
    </header>
  )
}

セレクタを別ファイルにまとめて再利用するチームも多くあります。複数のコンポーネントで同じセレクタを書き直す手間が省け、フィールド名を変更する際のリファクタリングも楽になります:

// store/selectors.js
export const selectUser      = (s) => s.user
export const selectCartItems = (s) => s.items
export const selectItemCount = (s) => s.items.length

// コンポーネントで使う: すっきりしてプロジェクト全体で一貫性が保てる
const user      = useStore(selectUser)
const itemCount = useStore(selectItemCount)

ポイント5: コンポーネントの外でstateを使う

Zustandはコンポーネントやhookの外でもstateの読み書きができます。axiosのインターセプター、WebSocketハンドラー、ユーティリティファイルなどで役立ちます:

// 現在のstateを読み取る
const currentUser = useUserStore.getState().user

// コンポーネントの外からstateを更新する
useUserStore.setState({ user: null })

subscribe でstateの変化を監視できます。シグネチャは2種類あります:

// 形式1: subscribe(listener) — store全体の変化を監視
// ミドルウェアなしでそのまま使える
const unsub = useUserStore.subscribe((state) => {
  console.log('Store changed:', state)
})

セレクタ付きの形式2は、storeを作成するときに subscribeWithSelector ミドルウェアを追加する必要があります。追加しない場合、エラーが出ることもなくlistenerが呼ばれないので注意してください:

import { subscribeWithSelector } from 'zustand/middleware'

// ミドルウェアを追加してstoreを定義
const useUserStore = create(
  subscribeWithSelector((set) => ({
    user: null,
    setUser: (user) => set({ user }),
  }))
)

// 形式2: subscribe(selector, listener) — 指定したstateが変化したときだけ発火
// selector: 監視したいstateを選択
// listener: (新しい値, 前の値) を受け取る
const unsub = useUserStore.subscribe(
  (state) => state.user,          // selector: userだけ監視
  (user, prevUser) => {           // listener: userが変わったときに実行
    console.log('User changed from', prevUser, 'to', user)
  }
)
// メモリリークを防ぐため、不要になったらsubscribeを解除する
unsub()

ポイント6: 深くネストしたstateにはImmerを使う

深くネストしたオブジェクトを更新するとき、イミュータブルなコードは非常に長くなり、途中のspreadを忘れてデータが消えるバグも発生しやすいです。immer ミドルウェアを使えば、直接「変更」するような書き方ができ、内部ではイミュータビリティが保証されます:

npm install immer
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

const useStore = create(
  immer((set) => ({
    user: {
      profile: { name: '', avatar: '' },
      address: { city: '', district: '' },
    },

    // 直接変更するように書くだけ — あとはImmerが処理してくれる
    setCity: (city) => set((state) => {
      state.user.address.city = city
    }),

    setName: (name) => set((state) => {
      state.user.profile.name = name
    }),
  }))
)

Immerなしの場合との比較:

// 良くない例: 冗長で、spreadを忘れると他のフィールドのデータが消える
setCity: (city) => set((state) => ({
  user: {
    ...state.user,
    address: { ...state.user.address, city },
  },
}))

まとめ

ユースケース 使うもの
基本のstore create + set
不要な再レンダリングを防ぐ セレクタ + useShallow
storeの中でAPIを呼ぶ 通常の非同期アクション
localStorageにstateを保存 persist ミドルウェア
大規模アプリ、複数ドメイン スライスパターン
コンポーネント外で使う getState() / setState() / subscribe()
深くネストしたオブジェクト immer ミドルウェア

Zustandにはstoreの構成に「唯一の正解」はありません。シンプルに始めて、必要に応じて拡張できる柔軟さが魅力です。ただし、常にZustandが必要なわけではありません。コンポーネントが1〜2つ程度でstateを共有するだけなら、useState + propsや useContext で十分です。Zustandが真価を発揮するのは、コンポーネントツリーの中で互いに関係のない複数の場所から同じstateを参照する場合——そこが、Zustandが本当に解決してくれる問題です。

9
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?