לדלג לתוכן

7.7 - ניהול סטייט - Zustand - הרצאה

ניהול סטייט - Zustand

בשיעור הזה נלמד על Zustand - ספריית ניהול state קלה ופשוטה לריאקט. Zustand מציעה API מינימלי עם יכולות חזקות, והיא אחת מספריות ניהול ה-state הפופולריות ביותר כיום.


למה ניהול סטייט חיצוני?

המגבלות של Context + useReducer

  • כל שינוי בקונטקסט מרנדר מחדש את כל הצרכנים
  • אין selectors - לא ניתן להאזין רק לחלק מה-state
  • ביצועים ירודים באפליקציות גדולות עם עדכונים תכופים
  • אין middleware מובנה (persist, devtools, logging)

למה Zustand?

  • API פשוט מאוד - פחות boilerplate מ-Redux
  • ביצועים טובים - רנדר רק כשהנתון שמאזינים לו משתנה
  • גודל קטן (כ-1KB)
  • עובד גם מחוץ לריאקט
  • Middleware מובנה (persist, devtools, immer)
  • אין צורך ב-Provider

התקנה

npm install zustand

יצירת Store בסיסי

import { create } from "zustand";

interface CounterStore {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  incrementBy: (amount: number) => void;
}

const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}));
  • create מקבל פונקציה שמגדירה את ה-state והפעולות
  • set מעדכן את ה-state (כמו setState, אבל מבצע merge רדוד)
  • ה-store מוחזר כהוק

שימוש בקומפוננטות

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

  return (
    <div>
      <p>מונה: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

function ResetButton() {
  const reset = useCounterStore((state) => state.reset);
  return <button onClick={reset}>אפס</button>;
}
  • כל קומפוננטה בוחרת את הנתונים שהיא צריכה באמצעות selector
  • הקומפוננטה תרנדר מחדש רק כשהנתון שנבחר משתנה
  • אין צורך ב-Provider!

Store מתקדם יותר

חנות מוצרים

import { create } from "zustand";

interface Product {
  id: number;
  name: string;
  price: number;
}

interface CartItem extends Product {
  quantity: number;
}

interface StoreState {
  cart: CartItem[];
  isCartOpen: boolean;

  // Actions
  addToCart: (product: Product) => void;
  removeFromCart: (productId: number) => void;
  updateQuantity: (productId: number, quantity: number) => void;
  clearCart: () => void;
  toggleCart: () => void;

  // Derived values (computed)
  getTotalItems: () => number;
  getTotalPrice: () => number;
}

const useStore = create<StoreState>((set, get) => ({
  cart: [],
  isCartOpen: false,

  addToCart: (product) =>
    set((state) => {
      const existing = state.cart.find((item) => item.id === product.id);
      if (existing) {
        return {
          cart: state.cart.map((item) =>
            item.id === product.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          ),
        };
      }
      return { cart: [...state.cart, { ...product, quantity: 1 }] };
    }),

  removeFromCart: (productId) =>
    set((state) => ({
      cart: state.cart.filter((item) => item.id !== productId),
    })),

  updateQuantity: (productId, quantity) =>
    set((state) => ({
      cart:
        quantity <= 0
          ? state.cart.filter((item) => item.id !== productId)
          : state.cart.map((item) =>
              item.id === productId ? { ...item, quantity } : item
            ),
    })),

  clearCart: () => set({ cart: [] }),
  toggleCart: () => set((state) => ({ isCartOpen: !state.isCartOpen })),

  // Using get to access the current state
  getTotalItems: () => {
    return get().cart.reduce((sum, item) => sum + item.quantity, 0);
  },

  getTotalPrice: () => {
    return get().cart.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  },
}));
  • get מאפשר גישה ל-state הנוכחי (שימושי ב-computed values)
  • set מבצע merge רדוד - צריך להחזיר רק את השדות שמשתנים

שימוש

function CartBadge() {
  const totalItems = useStore((state) => state.getTotalItems());
  return <span>סל ({totalItems})</span>;
}

function CartDrawer() {
  const cart = useStore((state) => state.cart);
  const isOpen = useStore((state) => state.isCartOpen);
  const removeFromCart = useStore((state) => state.removeFromCart);
  const updateQuantity = useStore((state) => state.updateQuantity);
  const getTotalPrice = useStore((state) => state.getTotalPrice);

  if (!isOpen) return null;

  return (
    <div>
      <h2>סל קניות</h2>
      {cart.map((item) => (
        <div key={item.id}>
          <span>{item.name}</span>
          <span>{item.price} ש"ח</span>
          <button onClick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
          <span>{item.quantity}</span>
          <button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
          <button onClick={() => removeFromCart(item.id)}>הסר</button>
        </div>
      ))}
      <p>סה"כ: {getTotalPrice()} ש"ח</p>
    </div>
  );
}

Middleware - תוספים

Persist - שמירה אוטומטית

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

interface SettingsStore {
  theme: "light" | "dark";
  language: string;
  fontSize: number;
  setTheme: (theme: "light" | "dark") => void;
  setLanguage: (language: string) => void;
  setFontSize: (size: number) => void;
}

const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: "light",
      language: "he",
      fontSize: 16,
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      setFontSize: (fontSize) => set({ fontSize }),
    }),
    {
      name: "settings-storage", // key in localStorage
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        // Persist only part of the state
        theme: state.theme,
        language: state.language,
        fontSize: state.fontSize,
      }),
    }
  )
);
  • persist שומר את ה-state ב-localStorage (או sessionStorage) אוטומטית
  • partialize מאפשר לבחור אילו שדות לשמור
  • ה-state משוחזר אוטומטית בטעינה הבאה

DevTools - כלי פיתוח

import { create } from "zustand";
import { devtools } from "zustand/middleware";

const useStore = create<StoreState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set((state) => ({ count: state.count + 1 }), false, "increment"),
      // The third parameter of set is the action name in devtools
    }),
    { name: "MyStore" }
  )
);
  • עובד עם Redux DevTools Extension בדפדפן
  • מאפשר לעקוב אחרי כל שינוי ב-state, time-travel debugging ועוד
  • הפרמטר השלישי ב-set מגדיר את שם הפעולה שיוצג ב-devtools

Immer - עדכון state אימיוטבלי בקלות

import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

interface TodoStore {
  todos: { id: number; text: string; done: boolean }[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  removeTodo: (id: number) => void;
}

const useTodoStore = create<TodoStore>()(
  immer((set) => ({
    todos: [],

    addTodo: (text) =>
      set((state) => {
        // You can mutate state directly - immer handles immutability
        state.todos.push({
          id: Date.now(),
          text,
          done: false,
        });
      }),

    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id);
        if (todo) todo.done = !todo.done;
      }),

    removeTodo: (id) =>
      set((state) => {
        const index = state.todos.findIndex((t) => t.id === id);
        if (index !== -1) state.todos.splice(index, 1);
      }),
  }))
);
  • בלי immer: set(state => ({ todos: state.todos.map(t => t.id === id ? {...t, done: !t.done} : t) }))
  • עם immer: set(state => { const todo = state.todos.find(t => t.id === id); if(todo) todo.done = !todo.done; })
  • הרבה יותר קריא, במיוחד עם state מקונן

שילוב Middleware

const useStore = create<StoreState>()(
  devtools(
    persist(
      immer((set) => ({
        // ...store definition
      })),
      { name: "my-storage" }
    ),
    { name: "MyStore" }
  )
);

Slices - פיצול ה-Store

באפליקציות גדולות, מומלץ לפצל את ה-store ל-slices:

import { create, StateCreator } from "zustand";

// Slice - users
interface UserSlice {
  user: { name: string; email: string } | null;
  login: (name: string, email: string) => void;
  logout: () => void;
}

const createUserSlice: StateCreator<
  UserSlice & CartSlice,
  [],
  [],
  UserSlice
> = (set) => ({
  user: null,
  login: (name, email) => set({ user: { name, email } }),
  logout: () => set({ user: null, cart: [] }), // also accesses the cart slice
});

// Slice - shopping cart
interface CartSlice {
  cart: { id: number; name: string; quantity: number }[];
  addToCart: (item: { id: number; name: string }) => void;
  clearCart: () => void;
}

const createCartSlice: StateCreator<
  UserSlice & CartSlice,
  [],
  [],
  CartSlice
> = (set) => ({
  cart: [],
  addToCart: (item) =>
    set((state) => ({
      cart: [...state.cart, { ...item, quantity: 1 }],
    })),
  clearCart: () => set({ cart: [] }),
});

// Combine all the slices
const useStore = create<UserSlice & CartSlice>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

// Usage
function Header() {
  const user = useStore((state) => state.user);
  const logout = useStore((state) => state.logout);
  const cartCount = useStore((state) => state.cart.length);

  return (
    <header>
      {user ? (
        <>
          <span>שלום, {user.name}</span>
          <span>סל ({cartCount})</span>
          <button onClick={logout}>התנתק</button>
        </>
      ) : (
        <span>אורח</span>
      )}
    </header>
  );
}

Zustand מול Context

מאפיין Context + useReducer Zustand
גודל מובנה בריאקט כ-1KB
Provider נדרש לא נדרש
Selectors לא (כל שינוי מרנדר הכל) כן (רנדר רק מה שהשתנה)
Middleware אין persist, devtools, immer
שימוש מחוץ לריאקט לא כן
Boilerplate הרבה (context, provider, reducer, types) מעט
מתאים ל state פשוט, ערכת נושא, שפה state מורכב, אפליקציות גדולות

טיפים ושיטות עבודה מומלצות

שימוש ב-selectors ממוקדים

// Not recommended - returns the entire store
const store = useStore();

// Recommended - selects only what's needed
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);

// You can also select multiple fields
const { count, increment } = useStore((state) => ({
  count: state.count,
  increment: state.increment,
}));

גישה ל-store מחוץ לריאקט

// Read
const currentCount = useCounterStore.getState().count;

// Update
useCounterStore.getState().increment();

// Listen for changes
const unsubscribe = useCounterStore.subscribe((state) => {
  console.log("State changed:", state.count);
});

סיכום

  • Zustand היא ספריית ניהול state קלה, פשוטה וחזקה
  • יצירת store עם create, שימוש עם selector בקומפוננטות
  • לא צריך Provider - ה-store זמין בכל מקום
  • רנדר חכם - קומפוננטה מתעדכנת רק כשהנתון שהיא מאזינה לו משתנה
  • Middleware מובנה: persist לשמירה, devtools לדיבאג, immer לעדכונים קלים
  • Slices מאפשרים פיצול store גדול לחלקים מנוהלים
  • API פשוט עם מעט boilerplate לעומת Redux