לדלג לתוכן

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;

  // פעולות
  addToCart: (product: Product) => void;
  removeFromCart: (productId: number) => void;
  updateQuantity: (productId: number, quantity: number) => void;
  clearCart: () => void;
  toggleCart: () => void;

  // ערכים נגזרים (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 })),

  // שימוש ב-get לגישה ל-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", // מפתח ב-localStorage
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        // שומר רק חלק מה-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"),
      // הפרמטר השלישי של set הוא שם הפעולה ב-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) => {
        // אפשר לשנות את state ישירות - immer מטפל באימיוטביליות
        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 - משתמשים
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: [] }), // ניגש גם ל-cart slice
});

// Slice - עגלת קניות
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: [] }),
});

// חיבור כל ה-Slices
const useStore = create<UserSlice & CartSlice>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

// שימוש
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 ממוקדים

// לא מומלץ - מחזיר את כל ה-store
const store = useStore();

// מומלץ - בוחר רק מה שצריך
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);

// אפשר גם לבחור מספר שדות
const { count, increment } = useStore((state) => ({
  count: state.count,
  increment: state.increment,
}));

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

// קריאה
const currentCount = useCounterStore.getState().count;

// עדכון
useCounterStore.getState().increment();

// האזנה לשינויים
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