7.7 ניהול סטייט Zustand פתרון
פתרון - ניהול סטייט - Zustand¶
פתרון תרגיל 1 - Store בסיסי - רשימת משימות¶
import { create } from "zustand";
interface Todo {
id: number;
text: string;
completed: boolean;
}
type Filter = "all" | "active" | "completed";
interface TodoStore {
todos: Todo[];
filter: Filter;
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
removeTodo: (id: number) => void;
editTodo: (id: number, text: string) => void;
setFilter: (filter: Filter) => void;
clearCompleted: () => void;
getFilteredTodos: () => Todo[];
getStats: () => { total: number; active: number; completed: number };
}
const useTodoStore = create<TodoStore>((set, get) => ({
todos: [],
filter: "all",
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), text, completed: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
),
})),
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter((t) => t.id !== id),
})),
editTodo: (id, text) =>
set((state) => ({
todos: state.todos.map((t) => (t.id === id ? { ...t, text } : t)),
})),
setFilter: (filter) => set({ filter }),
clearCompleted: () =>
set((state) => ({
todos: state.todos.filter((t) => !t.completed),
})),
getFilteredTodos: () => {
const { todos, filter } = get();
switch (filter) {
case "active":
return todos.filter((t) => !t.completed);
case "completed":
return todos.filter((t) => t.completed);
default:
return todos;
}
},
getStats: () => {
const { todos } = get();
const completed = todos.filter((t) => t.completed).length;
return {
total: todos.length,
active: todos.length - completed,
completed,
};
},
}));
// קומפוננטות
function TodoInput() {
const addTodo = useTodoStore((s) => s.addTodo);
const [text, setText] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim()) {
addTodo(text.trim());
setText("");
}
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="משימה חדשה..." />
<button type="submit">הוסף</button>
</form>
);
}
function TodoItem({ todo }: { todo: Todo }) {
const toggleTodo = useTodoStore((s) => s.toggleTodo);
const removeTodo = useTodoStore((s) => s.removeTodo);
return (
<li>
<span
onClick={() => toggleTodo(todo.id)}
style={{
textDecoration: todo.completed ? "line-through" : "none",
cursor: "pointer",
}}
>
{todo.text}
</span>
<button onClick={() => removeTodo(todo.id)}>מחק</button>
</li>
);
}
function TodoList() {
const getFilteredTodos = useTodoStore((s) => s.getFilteredTodos);
const filteredTodos = getFilteredTodos();
return (
<ul>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
function TodoFilters() {
const filter = useTodoStore((s) => s.filter);
const setFilter = useTodoStore((s) => s.setFilter);
const clearCompleted = useTodoStore((s) => s.clearCompleted);
return (
<div>
{(["all", "active", "completed"] as Filter[]).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
style={{ fontWeight: filter === f ? "bold" : "normal" }}
>
{f === "all" ? "הכל" : f === "active" ? "פעילות" : "הושלמו"}
</button>
))}
<button onClick={clearCompleted}>נקה שהושלמו</button>
</div>
);
}
function TodoStats() {
const getStats = useTodoStore((s) => s.getStats);
const stats = getStats();
return (
<p>
סה"כ: {stats.total} | פעילות: {stats.active} | הושלמו: {stats.completed}
</p>
);
}
הסבר:
- כל קומפוננטה בוחרת רק את מה שהיא צריכה מה-store
- TodoStats ו-TodoFilters לא מרנדרים מחדש כשמוסיפים משימה (הם לא מאזינים ל-todos ישירות)
- getFilteredTodos ו-getStats משתמשים ב-get() לגישה ל-state הנוכחי
פתרון תרגיל 2 - Store עם Persist¶
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
interface Note {
id: string;
title: string;
content: string;
color: string;
createdAt: string;
updatedAt: string;
}
type SortBy = "createdAt" | "updatedAt";
type ViewMode = "grid" | "list";
interface NotesStore {
notes: Note[];
searchQuery: string;
sortBy: SortBy;
viewMode: ViewMode;
createNote: (title: string, content: string, color?: string) => void;
updateNote: (id: string, updates: Partial<Pick<Note, "title" | "content" | "color">>) => void;
deleteNote: (id: string) => void;
setSearchQuery: (query: string) => void;
setSortBy: (sort: SortBy) => void;
setViewMode: (mode: ViewMode) => void;
getFilteredNotes: () => Note[];
}
const useNotesStore = create<NotesStore>()(
persist(
(set, get) => ({
notes: [],
searchQuery: "",
sortBy: "updatedAt" as SortBy,
viewMode: "grid" as ViewMode,
createNote: (title, content, color = "#ffffff") =>
set((state) => ({
notes: [
{
id: Date.now().toString(),
title,
content,
color,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
...state.notes,
],
})),
updateNote: (id, updates) =>
set((state) => ({
notes: state.notes.map((note) =>
note.id === id
? { ...note, ...updates, updatedAt: new Date().toISOString() }
: note
),
})),
deleteNote: (id) =>
set((state) => ({
notes: state.notes.filter((n) => n.id !== id),
})),
setSearchQuery: (query) => set({ searchQuery: query }),
setSortBy: (sort) => set({ sortBy: sort }),
setViewMode: (mode) => set({ viewMode: mode }),
getFilteredNotes: () => {
const { notes, searchQuery, sortBy } = get();
let filtered = notes;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(
(n) =>
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q)
);
}
return filtered.sort(
(a, b) => new Date(b[sortBy]).getTime() - new Date(a[sortBy]).getTime()
);
},
}),
{
name: "notes-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
notes: state.notes,
viewMode: state.viewMode,
sortBy: state.sortBy,
}),
}
)
);
// קומפוננטות
function NoteCard({ note }: { note: Note }) {
const updateNote = useNotesStore((s) => s.updateNote);
const deleteNote = useNotesStore((s) => s.deleteNote);
const colors = ["#ffffff", "#fff3cd", "#d1ecf1", "#d4edda", "#f8d7da", "#e2d5f1"];
return (
<div
style={{
backgroundColor: note.color,
padding: "16px",
borderRadius: "8px",
border: "1px solid #ddd",
}}
>
<h3>{note.title}</h3>
<p>{note.content}</p>
<p style={{ fontSize: "12px", color: "#666" }}>
עודכן: {new Date(note.updatedAt).toLocaleDateString("he-IL")}
</p>
<div>
{colors.map((c) => (
<button
key={c}
onClick={() => updateNote(note.id, { color: c })}
style={{
width: "20px",
height: "20px",
backgroundColor: c,
border: c === note.color ? "2px solid #333" : "1px solid #ccc",
borderRadius: "50%",
cursor: "pointer",
}}
/>
))}
<button onClick={() => deleteNote(note.id)}>מחק</button>
</div>
</div>
);
}
function NotesApp() {
const searchQuery = useNotesStore((s) => s.searchQuery);
const setSearchQuery = useNotesStore((s) => s.setSearchQuery);
const viewMode = useNotesStore((s) => s.viewMode);
const setViewMode = useNotesStore((s) => s.setViewMode);
const sortBy = useNotesStore((s) => s.sortBy);
const setSortBy = useNotesStore((s) => s.setSortBy);
const createNote = useNotesStore((s) => s.createNote);
const getFilteredNotes = useNotesStore((s) => s.getFilteredNotes);
const notes = getFilteredNotes();
return (
<div>
<h1>ההערות שלי</h1>
<div>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="חפש..."
/>
<button onClick={() => setViewMode(viewMode === "grid" ? "list" : "grid")}>
{viewMode === "grid" ? "תצוגת רשימה" : "תצוגת Grid"}
</button>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as SortBy)}>
<option value="updatedAt">לפי עדכון</option>
<option value="createdAt">לפי יצירה</option>
</select>
<button onClick={() => createNote("הערה חדשה", "")}>הערה חדשה</button>
</div>
<div
style={{
display: viewMode === "grid" ? "grid" : "flex",
gridTemplateColumns: "repeat(3, 1fr)",
flexDirection: "column",
gap: "16px",
}}
>
{notes.map((note) => (
<NoteCard key={note.id} note={note} />
))}
</div>
</div>
);
}
הסבר:
- persist שומר אוטומטית ב-localStorage - ההערות שמורות בין רענונים
- partialize מפסיק משמירת searchQuery (לא הגיוני לשמור חיפוש בין סשנים)
- צבעי ההערות ניתנים לשינוי דרך כפתורי צבע
פתרון תרגיל 3 - Store עם Immer¶
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
interface Task {
id: string;
title: string;
description: string;
}
interface Column {
id: string;
name: string;
tasks: Task[];
}
interface Board {
id: string;
name: string;
columns: Column[];
}
interface Project {
id: string;
name: string;
boards: Board[];
}
interface ProjectStore {
projects: Project[];
addProject: (name: string) => void;
removeProject: (projectId: string) => void;
addBoard: (projectId: string, name: string) => void;
removeBoard: (projectId: string, boardId: string) => void;
addTask: (projectId: string, boardId: string, columnId: string, task: Omit<Task, "id">) => void;
moveTask: (
projectId: string,
boardId: string,
fromColumnId: string,
toColumnId: string,
taskId: string
) => void;
editTask: (projectId: string, boardId: string, columnId: string, taskId: string, updates: Partial<Task>) => void;
removeTask: (projectId: string, boardId: string, columnId: string, taskId: string) => void;
}
const useProjectStore = create<ProjectStore>()(
immer((set) => ({
projects: [],
addProject: (name) =>
set((state) => {
state.projects.push({
id: Date.now().toString(),
name,
boards: [],
});
}),
removeProject: (projectId) =>
set((state) => {
const index = state.projects.findIndex((p) => p.id === projectId);
if (index !== -1) state.projects.splice(index, 1);
}),
addBoard: (projectId, name) =>
set((state) => {
const project = state.projects.find((p) => p.id === projectId);
if (project) {
project.boards.push({
id: Date.now().toString(),
name,
columns: [
{ id: "todo", name: "לביצוע", tasks: [] },
{ id: "progress", name: "בתהליך", tasks: [] },
{ id: "done", name: "הושלם", tasks: [] },
],
});
}
}),
removeBoard: (projectId, boardId) =>
set((state) => {
const project = state.projects.find((p) => p.id === projectId);
if (project) {
const index = project.boards.findIndex((b) => b.id === boardId);
if (index !== -1) project.boards.splice(index, 1);
}
}),
addTask: (projectId, boardId, columnId, task) =>
set((state) => {
const project = state.projects.find((p) => p.id === projectId);
const board = project?.boards.find((b) => b.id === boardId);
const column = board?.columns.find((c) => c.id === columnId);
if (column) {
column.tasks.push({ ...task, id: Date.now().toString() });
}
}),
moveTask: (projectId, boardId, fromColumnId, toColumnId, taskId) =>
set((state) => {
const project = state.projects.find((p) => p.id === projectId);
const board = project?.boards.find((b) => b.id === boardId);
if (!board) return;
const fromColumn = board.columns.find((c) => c.id === fromColumnId);
const toColumn = board.columns.find((c) => c.id === toColumnId);
if (!fromColumn || !toColumn) return;
const taskIndex = fromColumn.tasks.findIndex((t) => t.id === taskId);
if (taskIndex === -1) return;
const [task] = fromColumn.tasks.splice(taskIndex, 1);
toColumn.tasks.push(task);
}),
editTask: (projectId, boardId, columnId, taskId, updates) =>
set((state) => {
const project = state.projects.find((p) => p.id === projectId);
const board = project?.boards.find((b) => b.id === boardId);
const column = board?.columns.find((c) => c.id === columnId);
const task = column?.tasks.find((t) => t.id === taskId);
if (task) {
Object.assign(task, updates);
}
}),
removeTask: (projectId, boardId, columnId, taskId) =>
set((state) => {
const project = state.projects.find((p) => p.id === projectId);
const board = project?.boards.find((b) => b.id === boardId);
const column = board?.columns.find((c) => c.id === columnId);
if (column) {
const index = column.tasks.findIndex((t) => t.id === taskId);
if (index !== -1) column.tasks.splice(index, 1);
}
}),
}))
);
הסبر:
- בלי immer, moveTask היה דורש שכפול של כל רמות ה-state (project -> board -> columns)
- עם immer, אנחנו פשוט מוצאים את האלמנט ומשנים אותו ישירות
- splice, push, ו-Object.assign עובדים ישירות על ה-draft - immer מבצע את ה-immutable update מאחורי הקלעים
פתרון תרגיל 4 - Store עם Slices¶
import { create, StateCreator } from "zustand";
import { devtools } from "zustand/middleware";
// Auth Slice
interface AuthSlice {
user: { id: string; name: string; email: string } | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: () => boolean;
}
type StoreState = AuthSlice & CartSlice & ProductsSlice & UISlice;
const createAuthSlice: StateCreator<StoreState, ["zustand/devtools", never](<../../../"zustand/devtools", never.md>), [], AuthSlice> = (set, get) => ({
user: null,
token: null,
login: async (email, password) => {
await new Promise((r) => setTimeout(r, 1000));
set(
{
user: { id: "1", name: "משתמש", email },
token: "fake-token",
},
false,
"auth/login"
);
},
logout: () => {
set(
{ user: null, token: null, cart: [] },
false,
"auth/logout"
);
},
isAuthenticated: () => get().token !== null,
});
// Cart Slice
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartSlice {
cart: CartItem[];
addItem: (product: { id: number; name: string; price: number }) => void;
removeItem: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
getTotal: () => number;
clearCart: () => void;
}
const createCartSlice: StateCreator<StoreState, ["zustand/devtools", never](<../../../"zustand/devtools", never.md>), [], CartSlice> = (set, get) => ({
cart: [],
addItem: (product) =>
set(
(state) => {
const existing = state.cart.find((i) => i.id === product.id);
if (existing) {
return {
cart: state.cart.map((i) =>
i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { cart: [...state.cart, { ...product, quantity: 1 }] };
},
false,
"cart/addItem"
),
removeItem: (id) =>
set(
(state) => ({ cart: state.cart.filter((i) => i.id !== id) }),
false,
"cart/removeItem"
),
updateQuantity: (id, quantity) =>
set(
(state) => ({
cart: quantity <= 0
? state.cart.filter((i) => i.id !== id)
: state.cart.map((i) => (i.id === id ? { ...i, quantity } : i)),
}),
false,
"cart/updateQuantity"
),
getTotal: () => get().cart.reduce((sum, i) => sum + i.price * i.quantity, 0),
clearCart: () => set({ cart: [] }, false, "cart/clear"),
});
// Products Slice
interface ProductsSlice {
products: { id: number; name: string; price: number }[];
productsLoading: boolean;
productsError: string | null;
fetchProducts: () => Promise<void>;
}
const createProductsSlice: StateCreator<StoreState, ["zustand/devtools", never](<../../../"zustand/devtools", never.md>), [], ProductsSlice> = (set) => ({
products: [],
productsLoading: false,
productsError: null,
fetchProducts: async () => {
set({ productsLoading: true, productsError: null }, false, "products/fetchStart");
try {
await new Promise((r) => setTimeout(r, 1000));
const products = [
{ id: 1, name: "טלפון", price: 2999 },
{ id: 2, name: "אוזניות", price: 499 },
{ id: 3, name: "מטען", price: 99 },
];
set({ products, productsLoading: false }, false, "products/fetchSuccess");
} catch {
set({ productsError: "שגיאה בטעינה", productsLoading: false }, false, "products/fetchError");
}
},
});
// UI Slice
interface UISlice {
theme: "light" | "dark";
sidebarOpen: boolean;
toggleTheme: () => void;
toggleSidebar: () => void;
}
const createUISlice: StateCreator<StoreState, ["zustand/devtools", never](<../../../"zustand/devtools", never.md>), [], UISlice> = (set) => ({
theme: "light",
sidebarOpen: true,
toggleTheme: () =>
set(
(state) => ({ theme: state.theme === "light" ? "dark" : "light" }),
false,
"ui/toggleTheme"
),
toggleSidebar: () =>
set(
(state) => ({ sidebarOpen: !state.sidebarOpen }),
false,
"ui/toggleSidebar"
),
});
// חיבור
const useStore = create<StoreState>()(
devtools(
(...args) => ({
...createAuthSlice(...args),
...createCartSlice(...args),
...createProductsSlice(...args),
...createUISlice(...args),
}),
{ name: "AppStore" }
)
);
הסבר:
- כל slice מוגדר בנפרד עם StateCreator
- כל action מקבל שם (הפרמטר השלישי של set) שמוצג ב-Redux DevTools
- logout מנקה גם user וגם cart - דוגמה לאינטראקציה בין slices
- devtools עוטף את כל ה-store ומאפשר דיבאג עם Redux DevTools
פתרון תרגיל 5 - Store עם DevTools ובדיקות¶
// store.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface Task {
id: number;
text: string;
done: boolean;
}
interface TaskStore {
tasks: Task[];
addTask: (text: string) => void;
toggleTask: (id: number) => void;
removeTask: (id: number) => void;
getActiveTasks: () => Task[];
getDoneTasks: () => Task[];
}
export const useTaskStore = create<TaskStore>()(
devtools(
(set, get) => ({
tasks: [],
addTask: (text) =>
set(
(state) => ({
tasks: [...state.tasks, { id: Date.now(), text, done: false }],
}),
false,
"tasks/add"
),
toggleTask: (id) =>
set(
(state) => ({
tasks: state.tasks.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
),
}),
false,
"tasks/toggle"
),
removeTask: (id) =>
set(
(state) => ({ tasks: state.tasks.filter((t) => t.id !== id) }),
false,
"tasks/remove"
),
getActiveTasks: () => get().tasks.filter((t) => !t.done),
getDoneTasks: () => get().tasks.filter((t) => t.done),
}),
{ name: "TaskStore" }
)
);
// store.test.ts
import { useTaskStore } from "./store";
describe("TaskStore", () => {
beforeEach(() => {
// איפוס ה-store לפני כל בדיקה
useTaskStore.setState({ tasks: [] });
});
it("should start with empty tasks", () => {
const state = useTaskStore.getState();
expect(state.tasks).toEqual([]);
});
it("should add a task", () => {
useTaskStore.getState().addTask("משימה חדשה");
const state = useTaskStore.getState();
expect(state.tasks).toHaveLength(1);
expect(state.tasks[0].text).toBe("משימה חדשה");
expect(state.tasks[0].done).toBe(false);
});
it("should toggle a task", () => {
useTaskStore.getState().addTask("משימה");
const taskId = useTaskStore.getState().tasks[0].id;
useTaskStore.getState().toggleTask(taskId);
expect(useTaskStore.getState().tasks[0].done).toBe(true);
useTaskStore.getState().toggleTask(taskId);
expect(useTaskStore.getState().tasks[0].done).toBe(false);
});
it("should remove a task", () => {
useTaskStore.getState().addTask("משימה 1");
useTaskStore.getState().addTask("משימה 2");
const taskId = useTaskStore.getState().tasks[0].id;
useTaskStore.getState().removeTask(taskId);
expect(useTaskStore.getState().tasks).toHaveLength(1);
expect(useTaskStore.getState().tasks[0].text).toBe("משימה 2");
});
it("should return active tasks", () => {
useTaskStore.getState().addTask("פעילה");
useTaskStore.getState().addTask("הושלמה");
const doneId = useTaskStore.getState().tasks[1].id;
useTaskStore.getState().toggleTask(doneId);
const active = useTaskStore.getState().getActiveTasks();
expect(active).toHaveLength(1);
expect(active[0].text).toBe("פעילה");
});
it("should return done tasks", () => {
useTaskStore.getState().addTask("פעילה");
useTaskStore.getState().addTask("הושלמה");
const doneId = useTaskStore.getState().tasks[1].id;
useTaskStore.getState().toggleTask(doneId);
const done = useTaskStore.getState().getDoneTasks();
expect(done).toHaveLength(1);
expect(done[0].text).toBe("הושלמה");
});
});
הסبر:
- בדיקות Zustand פשוטות מאוד - לא צריך רנדר, פשוט קוראים ל-getState ולפעולות
- setState מאפשר איפוס ה-store בין בדיקות
- כל action מקבל שם שמוצג ב-Redux DevTools
תשובות לשאלות¶
-
יתרון selectors: ב-useContext, כל שינוי בכל חלק של ה-state מרנדר את כל הקומפוננטות שצורכות את הקונטקסט. ב-Zustand, selector מאפשר לקומפוננטה לבחור בדיוק מה היא צריכה, ולרנדר מחדש רק כשהנתון הספציפי הזה משתנה. זה הבדל ביצועים משמעותי באפליקציות גדולות.
-
למה אין Provider: Zustand יוצר store כמשתנה גלובלי (module-level). ה-store הוא singleton שקיים מחוץ לעץ הריאקט. ההוק useStore מתחבר ל-store הגלובלי ומאזין לשינויים. זה אומר שגם קוד לא-ריאקטי יכול לגשת ל-store דרך getState.
-
set עם אובייקט מול פונקציה:
set({ count: 5 })מגדיר ערך קבוע (merge רדוד).set(state => ({ count: state.count + 1 }))משתמש ב-state הנוכחי לחישוב הערך החדש. צריך פונקציה כשהערך החדש תלוי בערך הנוכחי (כמו increment), ואובייקט כשקובעים ערך מוחלט (כמו reset). -
מתי slices: כדאי לפצל כשה-store גדול ויש חלקים עצמאיים ברורים (auth, cart, ui). store אחד עדיף כשכל ה-state קשור (כמו store של טופס אחד) או כשהאפליקציה קטנה. כלל אצבע: אם ה-store מכיל יותר מ-15-20 שדות, כדאי לשקול פיצול.
-
state אסינכרוני: Zustand מטפל ב-async פשוט - הפעולות ב-store יכולות להיות async. קוראים ל-set מספר פעמים: פעם אחת לסימון loading, פעם שנייה לעדכון הנתונים אחרי ההצלחה, ופעם שלישית לשגיאה אם נכשל. אין צורך ב-middleware מיוחד כמו thunks ב-Redux.