0
0

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公式チュートリアルで挫折 → ChatGPTにチュートリアル作らせたら理解できた話【React + TypeScript】

Posted at

Zustand公式チュートリアルを読んでも分からない...

最近、業務のReactプロジェクトでZustandを採用することになりました。
そこで、まずは公式サイトでキャッチアップだ!とサイトを確認。
チュートリアルがあるので、早速手を動かしながらやってみることにしました。

...分からない。

Zustandを使ったコード箇所の説明は...?
なんかZustandというよりReactのチュートリアル?
急にcombineってあるけど何?

って感じで全然Zustandについての内容が理解できませんでした。

そこで、ChatGPT に質問してみたらスッと理解できたので、その内容を自分なりにまとめました。

対象読者

  • Zustand公式チュートリアルが理解できなかった
  • これからZustand公式チュートリアルをしようとしてる
  • Zustand初心者
  • TypeScriptでの書き方を知りたい

環境

"zustand": "^5.0.5"
"react": "^19.1.0",
"react-dom": "^19.1.0",
"vite": "^6.3.5"

本題に入る前に理解した今伝えたいこと

公式導入ページをよく読め!

公式導入ページ

ある程度理解した状態で導入ページを改めて確認したところ、このページにZustandの基本が詰まっています。
普通、最初に読むだろと思われるかもしれませんが、とりあえず手を動かしてみっか!ってなるタイプなんです...(説明書読まずに組み立て始めるタイプ)

同じようなタイプの人には強く言いたい。
このページちゃんと読んで!

とは言っても、翻訳だと分かりにくくて、飛ばしちゃう人もいると思うので、導入ページのコードにざっくりな説明を追記しておきます。

//zustandを使うときは、createをインポート
import { create } from 'zustand'

const useStore = create((set) => ({
  //初期状態を用意
  bears: 0, 
  //状態を変更するためのアクション
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
  updateBears: (newBears) => set({ bears: newBears }),
}))
function BearCounter() {
  //useStoreのbearsを表示(bears: 0だから0 bears around here...)
  const bears = useStore((state) => state.bears)
  return <h1>{bears} bears around here...</h1>
}

function Controls() {
  //useStoreのincreasePopulationを使う。クリックしたら0+1されてカウントが増える。
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

簡単じゃねえか!と思いました?
優秀ですね!
当時の自分はこれすらも分からず、チュートリアルに手を出して挫折しました...
そんな自分でも理解できるようなチュートリアルをChatGPTが用意してくれたので、紹介していきます。


ChatGPTが用意してくれたチュートリアル

公式のチュートリアルに挫折した自分は、ChatGPTに、以下のプロンプトを投げました。

Zustandを使いこなせるようになるための初心者向けハンズオンチュートリアルを作成してください。
Vite+react+typescriptの環境で行っていきたい。

初級編、中級編に分けて紹介してくれたので、ぜひ写経してみてください。
自分はこれらを写経していくことで、Zustandの使い方がわかりました。

以下、回答内容。

Zustand 初心者向けハンズオン(Vite + React + TypeScript)

✅ ゴール

  • Zustand の導入方法を理解する

  • 状態の作成・更新・取得ができる

  • 複数の状態・アクションを管理できる

  • 型定義(TypeScript)と開発パターンを理解する

準備

  1. プロジェクト作成
npm create vite@latest zustand-tutorial -- --template react-ts
cd zustand-tutorial
npm install
  1. 必要パッケージインストール
npm install zustand

📦 ステップ1:カウンター状態の作成

  1. ストア作成(src/store/counter.ts)
import { create } from 'zustand'

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

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}))
  1. カウンターコンポーネント(src/components/Counter.tsx)
import { useCounterStore } from '../store/counter'

export const Counter = () => {
  const { count, increment, decrement } = useCounterStore()

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={increment}></button>
      <button onClick={decrement}></button>
    </div>
  )
}
  1. App.tsx に表示
import './App.css'
import { Counter } from './components/Counter'

function App() {
  return (
    <div className="App">
      <h1>Zustand カウンター</h1>
      <Counter />
    </div>
  )
}

export default App

🧩 ステップ2:入力フォームと状態の双方向連携

  1. ストア作成(src/store/user.ts)
import { create } from 'zustand'

type UserState = {
  username: string
  setUsername: (name: string) => void
}

export const useUserStore = create<UserState>((set) => ({
  username: '',
  setUsername: (name) => set({ username: name }),
}))
  1. フォームコンポーネント(src/components/UserForm.tsx)
import { useUserStore } from '../store/user'

export const UserForm = () => {
  const { username, setUsername } = useUserStore()

  return (
    <div>
      <input
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        placeholder="ユーザー名を入力"
      />
      <p>こんにちは、{username || 'ゲスト'}さん!</p>
    </div>
  )
}
  1. App.tsx に追加
import { Counter } from './components/Counter'
import { UserForm } from './components/UserForm'

function App() {
  return (
    <div className="App">
      <h1>Zustand ハンズオン</h1>
      <UserForm />
      <Counter />
    </div>
  )
}

🧪 ステップ3:複数ストアと選択的購読
Zustand のパフォーマンスのために「状態の一部だけ購読」もできます。

// src/components/UserNameDisplay.tsx
import { useUserStore } from '../store/user'

export const UserNameDisplay = () => {
  const username = useUserStore((state) => state.username)

  return <div>表示専用:{username}</div>
}

✅ まとめ

学んだこと 内容
Zustand の基本 create() で状態と関数を定義
状態の取得 useStore() でコンポーネント内から利用
状態の更新 set() を使って状態変更
型安全な状態管理 TypeScript による型付け
状態の分離・再利用 ストアをファイルごとに分離し、スケーラブルに設計

ここまで写経してみての感想

  • 公式チュートリアルと違って、ファイル分割されているから実際に使用する感覚に近い
  • TypeScriptと組み合わせたときの書き方が分かった
  • 例がカウンターなど簡単で理解しやすい
  • const username = useUserStore((state) => state.username)が一部だけ取り出す書き方なのが分かった
  • 基本の使い方では、combineは使わなくても使える

これらのことが理解できただけでも自分的には相当助かりました。
特に、状態を取得するときの書き方ですが、公式だと以下のようにincreasePopulationだけを取得する書き方をしてるけど、

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

個人的にはChatGPTが教えてくれた、const { username, setUsername } = useUserStore()のように全部取得したうえで、分割代入しているのが自分には理解しやすかったです。
公式の取得の仕方が一部取得なことも、ChatGPTのおかげで分かりました。


Zustand 中級編ハンズオン(Vite + React + TypeScript)

✅ ゴール

  • リスト型の状態管理(Todo)
  • 非同期API呼び出しと状態の更新
  • persist ミドルウェアによるローカルストレージ永続化
  • ストアの分割設計と実践的な使い方

📝 ステップ4:Todoリストを Zustand で管理

  1. ストア作成(src/store/todo.ts)
import { create } from 'zustand'

type Todo = {
  id: number
  title: string
  completed: boolean
}

type TodoState = {
  todos: Todo[]
  addTodo: (title: string) => void
  toggleTodo: (id: number) => void
  removeTodo: (id: number) => void
}

export const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  addTodo: (title) =>
    set((state) => ({
      todos: [
        ...state.todos,
        { id: Date.now(), title, completed: false },
      ],
    })),
  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      ),
    })),
  removeTodo: (id) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),
}))
  1. コンポーネント(src/components/TodoList.tsx)
import { useState } from 'react'
import { useTodoStore } from '../store/todo'

export const TodoList = () => {
  const [text, setText] = useState('')
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore()

  const handleAdd = () => {
    if (text.trim()) {
      addTodo(text)
      setText('')
    }
  }

  return (
    <div>
      <h2>📝 Todoリスト</h2>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="新しいTodoを入力"
      />
      <button onClick={handleAdd}>追加</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <label style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
              />
              {todo.title}
            </label>
            <button onClick={() => removeTodo(todo.id)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  )
}
  1. App.tsx に追加
import { TodoList } from './components/TodoList'

function App() {
  return (
    <div className="App">
      <h1>Zustand 中級ハンズオン</h1>
      <UserForm />
      <Counter />
      <TodoList />
    </div>
  )
}

🔄 ステップ5:API からデータを取得(非同期)
例:JSONPlaceholder から投稿一覧を取得

  1. ストア作成(src/store/posts.ts)
import { create } from 'zustand'

type Post = {
  id: number
  title: string
  body: string
}

type PostState = {
  posts: Post[]
  loading: boolean
  error: string | null
  fetchPosts: () => Promise<void>
}

export const usePostStore = create<PostState>((set) => ({
  posts: [],
  loading: false,
  error: null,
  fetchPosts: async () => {
    set({ loading: true, error: null })
    try {
      const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
      const data = await res.json()
      set({ posts: data, loading: false })
    } catch (e) {
      set({ error: '取得失敗', loading: false })
    }
  },
}))
  1. コンポーネント(src/components/PostList.tsx)
import { useEffect } from 'react'
import { usePostStore } from '../store/posts'

export const PostList = () => {
  const { posts, fetchPosts, loading, error } = usePostStore()

  useEffect(() => {
    fetchPosts()
  }, [])

  if (loading) return <p>読み込み中...</p>
  if (error) return <p style={{ color: 'red' }}>{error}</p>

  return (
    <div>
      <h2>📮 投稿一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <strong>{post.title}</strong>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

💾 ステップ6:状態をローカルストレージに永続化
Zustand の persist ミドルウェアを使います。

todo.ts に永続化を適用

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

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

type TodoState = {
  todos: Todo[]
  addTodo: (title: string) => void
  toggleTodo: (id: number) => void
  removeTodo: (id: number) => void
}

export const useTodoStore = create(
  persist<TodoState>(
    (set) => ({
      todos: [],
      addTodo: (title) =>
        set((state) => ({
          todos: [...state.todos, { id: Date.now(), title, completed: false }],
        })),
      toggleTodo: (id) =>
        set((state) => ({
          todos: state.todos.map((todo) =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
          ),
        })),
      removeTodo: (id) =>
        set((state) => ({
          todos: state.todos.filter((todo) => todo.id !== id),
        })),
    }),
    { name: 'todo-storage' }
  )
)

この状態は localStorage に保存され、再読込してもデータが残ります!

🎉 完成:App.tsx 全体例(構成)

import { Counter } from './components/Counter'
import { UserForm } from './components/UserForm'
import { TodoList } from './components/TodoList'
import { PostList } from './components/PostList'

function App() {
  return (
    <div className="App">
      <h1>Zustand 完全ハンズオン</h1>
      <UserForm />
      <Counter />
      <TodoList />
      <PostList />
    </div>
  )
}

export default App

✅ 中級編まとめ

学んだこと 内容
配列/リスト状態の管理 add, toggle, remove などロジックを状態内で管理
非同期通信(fetch)対応 Zustand内で async/await を使用して状態更新
ミドルウェア活用 persist で localStorage に自動保存
UIとの連携 Zustand状態とReactコンポーネントをスムーズに連携

ここまで写経してみての感想

  • 配列や非同期通信も実際に使う場合に必要だから、例としてわかりやすい
  • 初級編をやったあとだから理解しやすかった
  • persistの設定だいぶ簡単だな!?

persistの使い道

persistの設定が簡単なのは分かったけど、実際にどんなときに使うんだろ?
localStorageしか無理?
と、気になったので、以下のプロンプトで聞いてみました。

persist で localStorage に自動保存の使い道の例は?

persist ミドルウェアを使って Zustand の状態を localStorage に自動保存するのは、ページをリロードしても状態が保持されていてほしいケースで非常に便利です。

✅ よくある「使い道」の具体例

シーン 保存したい状態 説明
🔓 ログイン状態の保持 ユーザー情報(userオブジェクトやtokenなど) 再ログインせずに済むようにする
🎨 ユーザー設定・テーマ ダークモード設定、言語設定など ユーザーが選んだテーマや言語を次回訪問時にも維持
🛒 ECサイトのカート カート内の商品リスト ページ遷移やリロードしてもカートの中身を消さないようにする
🧾 フォームの入力途中保存 入力中のフォームの状態 ブラウザを閉じても入力内容が残っているようにする
✅ チェック済み項目の記憶(Todoなど) チェック済みタスクのID一覧など 完了状態をリロード後も覚えておく
📍 最終閲覧位置 スクロール位置や閲覧済みID 次に開いたときに続きを読みやすくする

はい。分かりやすい。

ありがとうChatGPT

ChatGPTの作成してくれたチュートリアルのおかげで、Zustandの基本的にな使い方を理解することができました。
ChatGPTであれば、自分のレベルや状況に合わせて回答してくれるので分かりやすいですね。
また学習に疑問が出ても、それまでの流れを汲み取ったうえで回答してくれるので、ドキュメントにはない良さがあるんだと思います。

ただ絶対的に正しい情報は公式なのは間違いないので、必ず公式サイトはチェックしておきましょう!
Zustand公式サイト

combineやcreateStoreなどその他疑問に思って聞いたこと

この記事で紹介した内容やcombineやcreateStoreなどについても以下のChatGPTに聞いているので、よかったら見てみてください。
https://chatgpt.com/share/683bf677-f3b4-8009-9b8f-f517a29b34df

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?