モダンな状態管理の探求:Redux からZustand へ ― 実装から原理まで

はじめに

こんにちは、エンジニアの王です。 フロントエンドの開発者として、私はいくつかのプロジェクトでRedux Toolkitを使用してきました。Redux Toolkitは確かに強力な状態管理ツールですが、プロジェクトが進むにつれて、その構造的な複雑さを次第に強く感じるようになりました。 そこで、より軽量で使いやすい状態管理ライブラリを探し始めました。新しいソリューションの条件として、Redux Toolkitの堅牢性は維持しつつ、より簡潔なAPIと学習コストの低さを重視しました。その過程で出会ったのが、Zustandです。

Reduxの実装の複雑さ

シンプルなカウンターを管理するためにRedux Toolkitで必要なコード量を見てみましょう:

// store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer
  }
})

// store の型定義
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

// 型付きフックの定義
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

// counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from './store'

// 状態の型定義
interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0,
}

// スライスの作成と型定義
export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload
    },
  },
})

// コンポーネントでの使用
// Counter.tsx
import { useAppSelector, useAppDispatch } from './store'
import { increment, decrement } from './counterSlice'

export function Counter() {
  // 型付きフックの使用
  const count = useAppSelector((state) => state.counter.value)
  const dispatch = useAppDispatch()

  return (
    <div>
      <button onClick={() => dispatch(decrement())}>-</button>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  )
}

このRedux Toolkitの実装でも、以下のような課題が残ります:

  1. ファイル構成が複雑:slice、store、コンポーネントと、複数のファイルでの実装が必要
  2. 概念的な理解の必要性:createSlice、reducer、dispatch等の概念を理解する必要がある...初心者には理解が難しい
  3. 型サポートの設定:TypeScriptプロジェクトでは、追加の型定義の設定が必要
  4. ボイラープレートコードの存在:Redux Toolkitによって簡略化されているものの、なお一定量のセットアップコードが必要

これらの課題は、特に小規模なプロジェクトや、新しいチームメンバーの参加時に影響を与える可能性があります。

Zustandとの出会い:エレガントな代替案

代替案を探す過程で、Zustandを発見しました。Zustandで同じカウンター機能を実装してみましょう。

Poimandres(旧PMND)チームによって開発・メンテナンスされているZustandは、React向けの小さくて高速、そしてスケーラブルな状態管理ソリューションです。2019年にリリースされて以来、GitHubで48k以上のスターを獲得し、多くの開発者から支持されています。 GitHub: https://github.com/pmndrs/zustand

import { create } from 'zustand'

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

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

// コンポーネントでの使用
const Counter = () => {
  const { count, increment, decrement } = useStore()
  
  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  )
}

違いがわかりますか?Zustandを使用すると、以下のことが可能です:

  1. すべての状態ロジックを1つのファイルで完結
  2. 設定不要で、Providerのラップも不要
  3. 直感的なAPI:create関数でストアを作成し、set関数で状態を更新
  4. シンプルな理解で済む:actions、reducersの概念がなく、直接状態を変更可能

Zustandの魅力:具体的な改善点

Zustandの実装例を見てきましたが、このシンプルさの裏には、従来の課題を解決するための様々な工夫が詰まっています。Zustandを活用することで、以下のような具体的なメリットが得られます:

  1. 状態管理の簡素化

    • Providerやconnectの設定が不要でプロジェクト構造がクリーンに
    • コンポーネント内で直接状態にアクセス可能な設計
  2. TypeScriptとの親和性向上

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 }))
}));

この例からわかるように、型定義がシンプルかつ直感的で、開発者の負担を大きく軽減できます。

  1. 状態更新の柔軟性向上 Zustandは複雑な状態管理や非同期処理においても、シンプルで理解しやすい実装を可能にします。
    • 状態の直接的な修正が可能になり、コードの見通しが改善
    • 非同期操作もmiddlewareなしでスムーズに実装可能
// 非同期操作の実装例:TODOリストの取得
import { useEffect } from "react";
import { create } from "zustand";
const useStore = create((set) => ({
  todos: null,
  error: null,
  fetchData: async () => {
    try {
      const res = await fetch(`https://jsonplaceholder.typicode.com/todos`);
      const todos = await res.json();
      set({ todos });
    } catch (error) {
      set({ error });
    }
  },
}));

export default function App() {
  const { todos, fetchData, error } = useStore();
    useEffect(() => {
      fetchData();
    }, []);
    if (!todos) return <div>Loading...</div>;
    if (error) return <div>{error.message}</div>;
    return (
      <div>
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
           ))}
        </ul>
      </div>
   );
}
  1. パフォーマンスの最適化 Zustandは状態が変更された時に必要なコンポーネントのみを再レンダリングすることができます。Reduxと比較すると、より簡潔な実装方法でありながら、同等のパフォーマンス最適化を実現しています。主な特徴は以下の通りです:
    • 選択的な状態購読
    • shallow比較やカスタム比較関数を使用して不要な再レンダリングの防止
    // 基本的な購読
    const count = useStore(state => state.count);
    
    // 複数の状態を選択的に購読
    const { user, settings } = useStore(state => ({
      user: state.user,
      settings: state.settings
    }));

    // shallow比較を使用して再レンダリングを最適化
    const { todos, done } = useStore(
      state => ({ 
        todos: state.todos,
        done: state.done 
      }),
      shallow
    )

    // カスタム比較関数の使用
    const todoCount = useStore(
      state => state.todos.length,
      (a, b) => Math.abs(a - b) < 5
    )

シンプルさを追求したZustandは、チームの生産性と新規参入のしやすさを両立させ、まさに現代のReactアプリケーションが求める状態管理ソリューションと言えるでしょう。

実践的なTodoリストの実装: 実践からパフォーマンス最適化まで

前の内容では、Reduxの複雑さとZustandへの移行理由とについて説明しました。今回は、完全なTodoアプリケーションの例を通じて、Zustandの使用方法とパフォーマンス最適化の実践について詳しく見ていきます。

完全的なSource Code:https://github.com/WWK563388548/simple-zustand-example

TodoアプリケーションのURL:https://regal-empanada-b5967a.netlify.app/

基本実装:Todoアプリケーションの構築

Storeの設計

まず、機能を完備したTodo storeを作成します:

import { create } from "zustand";

export type Todo = {
  id: string;
  title: string;
  completed: boolean;
}

type StatusType = 'all' | 'completed' | 'incompleted';

type Store = {
  todos: Array<Todo>;
  status: StatusType;
  setStatus: (status: StatusType) => void;
  setTodos: (fn: (todos: Array<Todo>) => Array<Todo>) => void;
}

export const useTodoStore = create<Store>((set) => ({
 // 初期状態
  status: 'all',
  todos: [],
 // 状態更新メソッド
  setStatus(status: StatusType) {
    set({ status })
  },
  setTodos(fn: (todos: Array<Todo>) => Array<Todo>) {
    set((prev) => ({ todos: fn(prev.todos) }))
  },
}));

この設計はZustandの主要な利点を示しています:

  • TypeScriptフレンドリーな型定義
  • 状態と操作メソッドの一元管理
  • シンプルで直感的な更新API

コンポーネントの実装

Todoアプリケーションの各コンポーネントを実装しましょう。 まず、App.tsxコンポーネント

// src/App.tsx
import { FormEvent } from "react"
import { v4 as uuid } from "uuid";
import { useTodoStore } from "../stores/todoStore"
import StatusGroup from "./StatusGroup"
import FilteredItemList from "./FilteredItemList";

const App = () => {
  const { setTodos } = useTodoStore()
  const add = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const title = e.currentTarget.inputTitle.value
    e.currentTarget.inputTitle.value = ''
    setTodos((prevTodos) => [
      ...prevTodos,
      { title, completed: false, id: uuid() },
    ])
  }

  return (
    <form onSubmit={add}>
      <StatusGroup />
      <input name="inputTitle" placeholder="Type ..." />
      <FilteredItemList />
    </form>
  )
}

export default App

後は、FilteredItemList.tsxStatusGroup.tsxコンポーネント

// src/FilteredItemList.tsx
import { a, useTransition } from "@react-spring/web"
import { useTodoStore } from "../stores/todoStore"
import TodoItem from "./TodoItem"

const FilteredItemList = () => {
  const { todos, status } = useTodoStore()
  const filterTodo = todos.filter((todo) => {
    if (status === 'all') return true
    if (status === 'completed') return todo.completed
    return !todo.completed
  })
  const transitions = useTransition(filterTodo, {
    keys: (todo) => todo.id,
    from: { opacity: 0, height: 0 },
    enter: { opacity: 1, height: 40 },
    leave: { opacity: 0, height: 0 },
  })
  return transitions((style, item) => (
    <a.div className="item" style={style}>
      <TodoItem item={item} />
    </a.div>
  ))
}

export default FilteredItemList;

// src/StatusGroup.tsx
import { useTodoStore } from "../stores/todoStore"
import { Label } from "../components/ui/label"
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group"

const StatusGroup = () => {
  const { status, setStatus } = useTodoStore()
  return (
    <RadioGroup onValueChange={setStatus} value={status}>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="all" id="r1" />
        <Label htmlFor="r1">All</Label>
      </div>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="completed" id="r2" />
        <Label htmlFor="r2">Completed</Label>
      </div>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="incompleted" id="r3" />
        <Label htmlFor="r3">Incompleted</Label>
      </div>
    </RadioGroup>
  )
}

export default StatusGroup;

最後は、TodoItem.tsxコンポーネント:

// src/TodoItem.tsx
import { Todo } from "../lib/types"
import { useTodoStore } from "../stores/todoStore"

const TodoItem = ({ item }: { item: Todo }) => {
  const { setTodos } = useTodoStore()
  const { title, completed, id } = item

  const toggleCompleted = () =>
    setTodos((prevTodos) =>
      prevTodos.map((prevItem) =>
        prevItem.id === id ? { ...prevItem, completed: !completed } : prevItem,
      ),
    )

  const remove = () => {
    setTodos((prevTodos) => {
      return prevTodos.filter((prevItem) => {
        return prevItem.id !== id
      })
    })
  }

  return (
    <>
      <input type="checkbox" checked={completed} onChange={toggleCompleted} />
      <span style={{ textDecoration: completed ? 'line-through' : '' }}>
        {title}
      </span>
      <span onClick={remove}>X</span>
    </>
  )
}

export default TodoItem;

以上の実装により、ユーザーは以下の操作が可能になります:

  1. Todoの表示状態を「全て」「完了」「未完了」で切り替える
  2. 各Todoアイテムの完了状態を変更する
  3. 不要なTodoを削除する

これでTodoリストアプリケーションの基本機能が完成しました。シンプルながら、タスク管理に必要な主要機能を備えています。

パフォーマンスの問題と最適化

上記の実装では、いくつかのパフォーマンス問題が発生する可能性があります。具体例を通じて、これらの問題を理解し、解決方法を学びましょう。

レンダリングの問題を理解する

例えば、FilteredItemListコンポーネントで以下のような実装があったとします:

let renderCount = 0;
const FilteredItemList = () => {
  renderCount++;
  // store全体を購読してしまっている
  // ❌ 問題のある実装例
  const { todos, status } = useTodoStore();
  
  console.log('レンダリング回数:', renderCount);
  const filteredTodos = todos.filter(todo => {
    if (status === 'all') return true;
    if (status === 'completed') return todo.completed;
    return !todo.completed;
  });
  return (
    <div>
      {filteredTodos.map(todo => (
        <TodoItem key={todo.id} item={todo} />
      ))}
    </div>
  );
};

この実装には以下の問題があります:

  1. FilteredItemListコンポーネントがstore全体を購読しているため、todosstatus以外の状態が変更されても再レンダリングが発生
  2. StatusGroupコンポーネントで状態を変更すると、不必要な再レンダリングが発生する可能性がある

最適化手法

1. セレクター関数の使用

パフォーマンス最適化の最も基本的な方法は、useStore にセレクター関数を渡すことです。 セレクター関数を使用すると:

  • 必要な状態(この場合はtodos)のみを購読
  • 関係のない状態の変更による再レンダリングを防止
const FilteredItemList = () => {
  // 必要な状態だけを選択的に購読
  // ✅ 改善された実装:必要な状態のみを購読
  const todos = useTodoStore(state => state.todos);
  const status = useTodoStore(state => state.status);
  
  const filteredTodos = useMemo(() => {
    return todos.filter(todo => {
      if (status === 'all') return true;
      if (status === 'completed') return todo.completed;
      return !todo.completed;
    });
  }, [todos, status]);
  return (
    <div>
      {filteredTodos.map(todo => (
        <TodoItem key={todo.id} item={todo} />
      ))}
    </div>
  );
};
2. createSelectorsの活用

大規模なプロジェクトでは、全てのuseStore呼び出しにセレクター関数を手動で追加するのは:

  • 面倒
  • ミスが発生しやすい
  • コードが冗長になる

この課題を解決するため、ZustandはcreateSelectorsというユーティリティを提供しています。これを使用することで、セレクター関数を自動的に生成できます。

// storeの作成
const useStoreBase = create((set) => ({
  todos: [],
  status: 'all' as 'all' | 'completed' | 'incompleted',
  setStatus: (status: 'all' | 'completed' | 'incompleted') => set({ status }),
  setTodos: (updateFn: (todos: Todo[]) => Todo[]) => 
    set((state) => ({ todos: updateFn(state.todos) }))
}));
// セレクターを自動生成
const useTodoStore = createSelectors(useStoreBase);
// 最適化されたコンポーネント
const FilteredItemList = () => {
  const todos = useTodoStore.use.todos();
  const status = useTodoStore.use.status();
  // ...
};
3. 浅い比較の使用

デフォルトでは、状態が変更された時、以下のプロセスで再レンダリングの要否が判断されます:

  1. セレクター関数を使用して最新の状態を計算
  2. Object.isを使用して、前回の状態と新しい状態を比較
  3. 比較結果に基づいて再レンダリングを判断

例えば:

const { todos, setStatus } = useStore((state) => ({
  todos: state.todos,
  setStatus: state.setStatus
}))

このコードの実行フロー:

  1. 初回レンダリング

    • セレクター関数が実行され、todossetStatusを含む新しいオブジェクトを生成
    • このオブジェクトが初期状態として保存
  2. statusフィールドが更新された場合:

    • セレクター関数が再度実行される
    • 新しいオブジェクトが生成される
    • Object.isで比較が行われる
    • todosとsetStatusの値自体は変更されていないが、新しいオブジェクトが生成されているため、Object.isはfalseを返す
    • 結果として不要な再レンダリングが発生

この問題を解決するため、ZustandはuseShallowで浅い比較を提供しています:

  • オブジェクトの最上位のプロパティ(この場合todosとsetStatus)それぞれについて個別に比較を行う
  • プロパティの値が実際に変更された場合のみ再レンダリングが発生
  • 無駄な再レンダリングを防ぎ、パフォーマンスが向上

このように、浅い比較を使用することで、オブジェクトの参照変更だけでは再レンダリングが発生せず、実際の値の変更があった場合のみ再レンダリングが行われるようになります。

const TodoSummary = () => {
  // ✅ `useShallow`で浅い比較をする
  const { todos, setStatus } = useTodoStore(
    useShallow(state => ({
      todos: state.todos,
      setStatus: state.setStatus
    }))
  );
  
  // ...
};

Zustand の核心的なAPI の解説と自作による原理理解

重要な前提

Zustand v5からはReact 18に依存するようになり、useSyncExternalStoreというReactの組み込みAPIを直接利用しています。これは重要な変更点であり、以下の特徴があります:

  • React 18ではuseSyncExternalStoreが組み込みAPIとして提供され、外部ストアとの同期を効率的に行えます
  • React 18未満のバージョンを使用する場合は、use-sync-external-storeのshimをインポートする必要があります

補足:useSyncExternalStoreはReact Reduxなども採用している外部ストア同期のための公式APIです。 これにより、並行レンダリング環境での信頼性の高い状態管理が実現できます。 しかし、Zustandはより軽量で設定が簡単ですよ

本文では、use-sync-external-storeパッケージのuseSyncExternalStoreWithSelectorを採用しております。Zustand5の公式実装方法については、Zustandのソースコードをご参照ください

Zustandの基本構造

Zustandの内部実装は非常にエレガントで理解しやすい構造になっています。主に以下の3つのコア機能で構成されています:

  • createStore: ストアの作成と管理
  • useStore: React Hooksとの連携
  • create: 上記2つを組み合わせた最終的なAPI

事前に型の準備

基本構造と各基礎的な機能を実装する前に、まずStoreの型定義について説明していきましょう。 (ユーザーがどのような値を渡すかわからないため、ジェネリックTを使用して実装していきます)

まず、現在の状態を取得する関数getStateの型を定義します:

type GetState<T> = () => T

次はsetStateの型定義 setStatecreate関数のコールバックパラメータとして使用され、Storeの状態を更新するために使用されます。以下の3つの使用パターンがあります:

  • 完全な状態更新
    • 完全な状態オブジェクトを渡して、現在の状態全体を置き換えます
    • 状態全体を一度に更新する場合に使用します
  • 部分的な状態更新
    • 状態の一部のみを渡します
    • Zustandは自動的に既存の状態と新しい部分的な状態をマージします
    • 特定のフィールドのみを更新する場合に便利です
  • 関数による更新
    • 現在の状態に基づいて計算や処理を行う関数を渡します
    • 関数は現在の状態を受け取り、新しい状態または部分的な状態を返します
    • 返された結果は現在の状態にマージされます

これらの使用パターンに対応する型定義:

type SetState<T> = (
  partial: T | Partial<T> | ((state: T) => T | Partial<T>),
) => void

そして、subscribe関数はuseSyncExternalStoreWithSelectorの引数として使用されるため、その型定義を流用します:

// subscribeの型定義
type Subscribe = Parameters<typeof useSyncExternalStoreWithSelector>[0]

最後に、これまでのすべてのAPI型を含むStore全体の型を定義します:

// Store APIの型定義
type StoreApi<T> = {
  setState: SetState<T>
  getState: GetState<T>
  subscribe: Subscribe
}

基本構造の実装

Zustandのcreate関数には以下の3つの特徴があります:

  1. 関数を引数として受け取る
  2. Storeを作成する
  3. 内部状態にアクセスするためのフックを返す

これらの特徴を踏まえて、基本的な実装を見ていきましょう。

import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'

type Subscribe = Parameters<typeof useSyncExternalStoreWithSelector>[0]
type GetState<T> = () => T
type SetState<T> = (
  partial: T | Partial<T> | ((state: T) => T | Partial<T>),
) => void
type StoreApi<T> = {
  setState: SetState<T>
  getState: GetState<T>
  subscribe: Subscribe
}

const createStore = <T>(createState): StoreApi<T> => {
    let state;  // storeの内部状態はstateに保存される
    const getState = () => state; 
    const setState: SetState<T> = () => {}; // setStateはcreateが受け取る関数の引数
    // subscribeするたびにsubscribeをlistenersに追加し、コンポーネントの再レンダリングをトリガーする
    const subscribe: Subscribe = () => {};
    const api = { getState, setState, subscribe };
    state = createState(setState); // stateの初期値はcreateStateの呼び出し結果
    return api;
}

const useStore = (api, selector, equalityFn) => {};

export const create = (createState) => {
  // storeを取得し、storeを操作するすべてのメソッドを含む
  const api = createStore(createState);
  const useBoundStore = (selector, equalityFn) =>
    useStore(api, selector, equalityFn);
  return useBoundStore;
};

createStoreはStoreを作成し、内部状態とStoreを操作するためのAPI関数を管理します。主に以下の機能を提供します:

  • getState(): Store内の現在の状態を取得するための関数
  • setState(): Storeの状態を更新するための関数
  • subscribe(): コンポーネントがStoreを購読するための機能。状態が変更された際に、購読しているコンポーネントの再レンダリングをトリガー

useBoundStoreは2つの重要なパラメータを受け取るカスタムフックです:

  • selector: Storeの完全な状態から必要な部分だけを選択する関数。状態の一部分だけを使用する場合のパフォーマンス最適化に役立つ
  • equalityFn: 選択された状態の変更を比較するための関数。状態が実際に変更されたかどうかを判断し、不要な再レンダリングを防ぐ

useStoreuseSyncExternalStoreWithSelector(useSyncExternalStoreWithSelectorはReactの組み込み機能です)を利用して以下の機能を提供します:

  • Storeの購読管理
  • 状態の選択的取得
  • レンダリングの最適化
  • 選択された状態の返却

createは上記のすべての機能を組み合わせて、最終的なAPIを提供します:

  • createStoreでStoreを作成
  • useStoreを通じて状態管理を実現
  • useBoundStoreで状態の選択と最適化を可能に

useStoreの実装

create関数から返されるフックであるuseBoundStoreselectorequalityFnを受け取り、これらのパラメータをapiと共にuseStore関数に渡すことで、状態管理の機能を実現しています。

useStoreの実装は比較的単純な構造となっており、主にuseSyncExternalStoreWithSelectorという関数を活用しています。この関数は状態の購読や更新を管理するために、5つの重要なパラメータを必要とします。具体的には、api.subscribeによる状態変更の購読機能、api.getStateによるクライアントおよびサーバーサイドでの状態取得機能、渡されたselectorによる状態の計算機能、そしてequalityFnによる状態変更の比較機能が含まれています。

selectorパラメータにおいては、その指定が任意となっており、当該パラメータの指定を省略した場合においてはストアに保持される全状態が返却されることとなりますが、一方で当該パラメータを指定した場合においては、指定されたselector関数の実行結果がslice値として返却されることとなります。

これらの要素を組み合わせた実装コードは以下のようになります:

const useStore = <State, StateSlice>(
  api: StoreApi<State>,
  selector: (state: State) => StateSlice = api.getState as any,
  equalityFn: (a: StateSlice, b: StateSlice) => boolean
) => {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getState,
    selector,
    equalityFn
  );
  return slice;
};

subscribeの実装

コンポーネントがStoreから状態を取得する際には、Storeに対する購読(subscribe)が必要となりますが、これはStore内部の状態が変更された際にコンポーネントの再レンダリング(re-render)を確実に実行するために不可欠な仕組みとなっています。

この購読の仕組みを実現するために、以下のような実装を行っています:

type StateCreator<T> = (setState: SetSet<T>) => T

const createStore = <T>(createState: StateCreator<T>): StoreApi<T> => {
  const listeners = new Set<() => void>();
  let state: T;
  const setState = () => {};
  const getState = () => state;
  const subscribe: Subscribe = (subscribe) => {
    listeners.add(subscribe);
    return () => listeners.delete(subscribe);
  };
  const api = { setState, getState, subscribe };
  state = createState(setState);
  return api;
};
//...

この実装において特に重要な点は、購読関数(subscribe)がコンポーネントの再レンダリングを実行するための関数をパラメータとして受け取り、それをSet構造体に保存する仕組みを採用していることです。さらに、コンポーネントがUnmountされる際に購読を解除するための関数を返却することで、メモリリークを防止するための配慮もなされています。

先に実装したcreateStore関数と比較すると、今回の実装ではlistenersというSet構造体を新たに導入しており、これによってStoreの状態が変更された際(つまりsetStateが呼び出された際)に、保存されているすべての関数を順次実行することで、該当するStoreを購読しているすべてのコンポーネントの再レンダリングを効率的に実現することが可能となっています。 このような実装アプローチを採用することにより、以下のような利点が得られています:

  • Set構造体を使用することで、同一のlistenerが重複して登録されることを防止しつつ、効率的なlistenerの管理が可能となっています。
  • コンポーネントのライフサイクルに応じた適切な購読管理が実現され、不要なメモリ消費を防ぐことができています。
  • Storeの状態変更時に、関連するすべてのコンポーネントを確実に更新することができ、アプリケーションの一貫性が保たれています。

setStateの実装

subscribeの実装に関する前述の説明から、コンポーネントの再レンダリングをトリガーするすべての関数がlistenersに保存され、状態が変更された際にはこれらの関数を順次実行する必要があ ることが理解できました。このような仕組みを踏まえ、setStateの実装においては以下の2つの処理を実現する必要があります:

  1. storeの状態を更新すること
  2. listenersに保存されているすべてのパラメータを順次実行すること

これらの処理を実現するにあたり、以下のような異なるケースについて個別に考察する必要があります。

関数をパラメータとして受け取る場合

以下のような実装例を考えてみましょう:

const useDataStore = create((set) => ({
  data: { count: 0, text: 'test' },
  inc: () => 
    set((state) => ({
      data: { ...state.data, count: state.data.count + 1 },
    })),
}))

この実装例においては、データを保存するためのdataパラメータと、countを1増加させるためのinc関数を定義しており、特筆すべき点として、set関数に対して状態更新用の関数を渡してい ることが挙げられます。この関数は前回の状態を表すstateを受け取り、それに基づいて新しいcount値を計算する仕組みとなっています。 したがって、実装においては受け取ったパラメータが関数であるかどうかを判定し、関数である場合にはそれを実行する処理が必要となります。

具体的な値を受け取る場合

次のような実装例も考えられます:

const useDataStore = create((set) => ({
  count: 1,
  text: 'test',
  setCount: () => set({ count: 10 }),
}))
最終的な実装

これらの要件を満たす実装は以下のようになります:

const setState: SetState<T> = (partial) => {
  const nextState =
    typeof partial === "function"
      ? (partial as (state: T) => T)(state)
      : partial;
  
  if (!Object.is(nextState, state)) {
    state =
      typeof nextState !== "object" || nextState === null
        ? (nextState as T)
        : Object.assign({}, state, nextState);
    listeners.forEach((listener) => listener());
  }
};

この実装の重要な特徴として、状態を更新する際にすべての状態を明示的に指定する必要がないという点が挙げられます。例えば、() => set((state) => ({ ...state, count: 10 }))のように 全状態を記述する代わりに、更新したい部分のデータのみを渡すことで、ZustandがObject.assign({}, state, nextState)を使用して自動的に状態の統合を行ってくれます。

さらに、状態の更新においては完全な置き換えではなくパッチ方式を採用しているため、まず渡されたパラメータの型を判定し、Objectであり、かつnullでない場合にのみ統合処理を実行する という方式を採用しています。また、パフォーマンスの最適化として、前回のstateと計算されたnextStateが異なる場合にのみ再レンダリングがトリガーされる仕組みも実装されています。

おわりに

Reduxの課題から始まり、Zustandの基本実装、実践的なTodoアプリケーションの構築、そしてライブラリの核心的な仕組みまで、幅広く探求してきました。Zustandは単なるReduxの代替ではなく、モダンなReactアプリケーション開発における新しい状態管理のパラダイムを示していると言えます。

特に重要だと思っているポイントは、Zustandが実現した「シンプルさと柔軟性の共存」です。ボイラープレートコードを最小限に抑えながら、強力な状態管理機能を提供する approach は、開発者の生産性を大きく向上させます。また、TypeScriptとの優れた親和性や、パフォーマンス最適化のための充実したAPIは、大規模アプリケーション開発における実用性の高さを示しています。

今後のフロントエンド開発において、状態管理はますます重要な課題となっていくでしょう。その中で、Zustandの示す「必要十分な機能」と「直感的なAPI設計」という方向性は、次世代の状態管理ライブラリの在り方を示唆しているのかもしれません。