7.3 קונטקסט ו useReducer הרצאה
קונטקסט ו-useReducer - Context and useReducer¶
בשיעור הזה נלמד על שני מנגנונים חשובים בריאקט: Context API לשיתוף נתונים בין קומפוננטות, ו-useReducer לניהול state מורכב. נלמד גם איך לשלב ביניהם ליצירת מנגנון ניהול state מלא.
בעיית ה-Prop Drilling¶
מה זה Prop Drilling?¶
כשצריכים להעביר נתונים מקומפוננטה עליונה לקומפוננטה עמוקה בעץ, נאלצים להעביר את ה-props דרך כל הקומפוננטות באמצע - גם אם הן לא צריכות את הנתונים:
function App() {
const [user, setUser] = useState({ name: "דני", role: "admin" });
return <Layout user={user} />;
}
function Layout({ user }: { user: User }) {
return (
<div>
<Header user={user} />
<Main user={user} />
</div>
);
}
function Header({ user }: { user: User }) {
return (
<header>
<Navigation user={user} />
</header>
);
}
function Navigation({ user }: { user: User }) {
return <nav>שלום, {user.name}</nav>;
}
- הקומפוננטות Layout ו-Header לא באמת צריכות את user - הן רק מעבירות אותו הלאה
- ככל שהעץ עמוק יותר, הבעיה מחמירה
- שינוי בטיפוס של user דורש עדכון בכל הקומפוננטות בשרשרת
הקונטקסט - Context API¶
יצירת קונטקסט - createContext¶
import { createContext } from "react";
interface User {
name: string;
role: string;
}
interface UserContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
const UserContext = createContext<UserContextType | null>(null);
- createContext יוצר אובייקט קונטקסט עם ערך ברירת מחדל
- הטיפוס הגנרי מגדיר את מבנה הנתונים שהקונטקסט יכיל
ספק הקונטקסט - Context Provider¶
import { useState } from "react";
function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = (userData: User) => {
setUser(userData);
};
const logout = () => {
setUser(null);
};
return (
<UserContext.Provider value={{ user, login, logout }}>
{children}
</UserContext.Provider>
);
}
- הספק עוטף את הקומפוננטות שצריכות גישה לקונטקסט
- כל הילדים (בכל עומק) יכולים לגשת לערך
צריכת הקונטקסט - useContext¶
import { useContext } from "react";
function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUser must be used within UserProvider");
}
return context;
}
function Navigation() {
const { user, logout } = useUser();
return (
<nav>
{user ? (
<div>
<span>שלום, {user.name}</span>
<button onClick={logout}>התנתק</button>
</div>
) : (
<span>אורח</span>
)}
</nav>
);
}
- יצרנו הוק מותאם אישית useUser שעוטף את useContext עם בדיקת שגיאה
- זה מבטיח שהשימוש בקונטקסט תמיד יהיה בתוך Provider
שימוש ב-Provider¶
function App() {
return (
<UserProvider>
<Layout />
</UserProvider>
);
}
function Layout() {
return (
<div>
<Header />
<Main />
</div>
);
}
function Header() {
return (
<header>
<Navigation />
</header>
);
}
- עכשיו Layout ו-Header לא צריכות להעביר את user - הן אפילו לא יודעות עליו
- Navigation ניגשת ישירות לקונטקסט דרך useUser
דוגמה - קונטקסט של ערכת נושא - Theme Context¶
import { createContext, useContext, useState } from "react";
type Theme = "light" | "dark";
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
{theme === "light" ? "מצב כהה" : "מצב בהיר"}
</button>
);
}
function Card({ title, content }: { title: string; content: string }) {
const { theme } = useTheme();
const styles = {
backgroundColor: theme === "light" ? "#fff" : "#333",
color: theme === "light" ? "#000" : "#fff",
padding: "20px",
borderRadius: "8px",
};
return (
<div style={styles}>
<h3>{title}</h3>
<p>{content}</p>
</div>
);
}
הוק useReducer¶
מה זה useReducer?¶
- useReducer הוא אלטרנטיבה ל-useState לניהול state מורכב
- במקום לעדכן state ישירות, שולחים "פעולות" (actions) ל-reducer שמחליט איך לעדכן
- התבנית מוכרת מ-Redux ומספקת עדכוני state צפויים ומאורגנים
תחביר בסיסי¶
reducer- פונקציה שמקבלת state נוכחי ו-action, ומחזירה state חדשinitialState- הערך ההתחלתי של ה-statestate- ה-state הנוכחיdispatch- פונקציה לשליחת actions
דוגמה בסיסית - מונה¶
import { useReducer } from "react";
interface CounterState {
count: number;
}
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" }
| { type: "set"; payload: number };
function counterReducer(
state: CounterState,
action: CounterAction
): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return { count: 0 };
case "set":
return { count: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>מונה: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>אפס</button>
<button onClick={() => dispatch({ type: "set", payload: 100 })}>
קבע 100
</button>
</div>
);
}
דוגמה מתקדמת - ניהול טופס¶
import { useReducer } from "react";
interface FormState {
values: {
name: string;
email: string;
message: string;
};
errors: Record<string, string>;
isSubmitting: boolean;
isSubmitted: boolean;
}
type FormAction =
| { type: "field_change"; field: string; value: string }
| { type: "set_error"; field: string; error: string }
| { type: "clear_errors" }
| { type: "submit_start" }
| { type: "submit_success" }
| { type: "submit_error"; error: string }
| { type: "reset" };
const initialFormState: FormState = {
values: { name: "", email: "", message: "" },
errors: {},
isSubmitting: false,
isSubmitted: false,
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "field_change":
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: "" },
};
case "set_error":
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
};
case "clear_errors":
return { ...state, errors: {} };
case "submit_start":
return { ...state, isSubmitting: true, errors: {} };
case "submit_success":
return { ...initialFormState, isSubmitted: true };
case "submit_error":
return {
...state,
isSubmitting: false,
errors: { form: action.error },
};
case "reset":
return initialFormState;
default:
return state;
}
}
function ContactForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: "submit_start" });
try {
await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(state.values),
});
dispatch({ type: "submit_success" });
} catch {
dispatch({ type: "submit_error", error: "שליחה נכשלה" });
}
};
if (state.isSubmitted) {
return (
<div>
<p>ההודעה נשלחה בהצלחה!</p>
<button onClick={() => dispatch({ type: "reset" })}>שלח עוד</button>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.name}
onChange={(e) =>
dispatch({ type: "field_change", field: "name", value: e.target.value })
}
placeholder="שם"
/>
{state.errors.name && <p style={{ color: "red" }}>{state.errors.name}</p>}
<input
value={state.values.email}
onChange={(e) =>
dispatch({ type: "field_change", field: "email", value: e.target.value })
}
placeholder="אימייל"
/>
{state.errors.email && <p style={{ color: "red" }}>{state.errors.email}</p>}
<textarea
value={state.values.message}
onChange={(e) =>
dispatch({ type: "field_change", field: "message", value: e.target.value })
}
placeholder="הודעה"
/>
{state.errors.form && <p style={{ color: "red" }}>{state.errors.form}</p>}
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? "שולח..." : "שלח"}
</button>
</form>
);
}
שילוב קונטקסט עם useReducer¶
השילוב של Context ו-useReducer מאפשר ליצור מנגנון ניהול state גלובלי, בדומה ל-Redux:
import { createContext, useContext, useReducer } from "react";
// טיפוסים
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
total: number;
}
type CartAction =
| { type: "add_item"; payload: Omit<CartItem, "quantity"> }
| { type: "remove_item"; payload: number }
| { type: "update_quantity"; payload: { id: number; quantity: number } }
| { type: "clear_cart" };
// ה-Reducer
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "add_item": {
const existing = state.items.find((i) => i.id === action.payload.id);
let newItems: CartItem[];
if (existing) {
newItems = state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
newItems = [...state.items, { ...action.payload, quantity: 1 }];
}
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0),
};
}
case "remove_item": {
const newItems = state.items.filter((i) => i.id !== action.payload);
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0),
};
}
case "update_quantity": {
const newItems = state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: Math.max(0, action.payload.quantity) }
: item
).filter((i) => i.quantity > 0);
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0),
};
}
case "clear_cart":
return { items: [], total: 0 };
default:
return state;
}
}
// הקונטקסט
interface CartContextType {
state: CartState;
dispatch: React.Dispatch<CartAction>;
}
const CartContext = createContext<CartContextType | null>(null);
function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error("useCart must be used within CartProvider");
}
return context;
}
// ספק
function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
total: 0,
});
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
// קומפוננטות
function ProductCard({
product,
}: {
product: { id: number; name: string; price: number };
}) {
const { dispatch } = useCart();
return (
<div>
<h3>{product.name}</h3>
<p>{product.price} ש"ח</p>
<button
onClick={() => dispatch({ type: "add_item", payload: product })}
>
הוסף לסל
</button>
</div>
);
}
function CartSummary() {
const { state, dispatch } = useCart();
return (
<div>
<h3>סל קניות ({state.items.length} פריטים)</h3>
{state.items.map((item) => (
<div key={item.id}>
<span>
{item.name} x{item.quantity} - {item.price * item.quantity} ש"ח
</span>
<button
onClick={() =>
dispatch({
type: "update_quantity",
payload: { id: item.id, quantity: item.quantity - 1 },
})
}
>
-
</button>
<button
onClick={() =>
dispatch({
type: "update_quantity",
payload: { id: item.id, quantity: item.quantity + 1 },
})
}
>
+
</button>
<button
onClick={() => dispatch({ type: "remove_item", payload: item.id })}
>
הסר
</button>
</div>
))}
<p>סה"כ: {state.total} ש"ח</p>
<button onClick={() => dispatch({ type: "clear_cart" })}>
רוקן סל
</button>
</div>
);
}
// אפליקציה
function App() {
const products = [
{ id: 1, name: "חולצה", price: 99 },
{ id: 2, name: "מכנסיים", price: 149 },
{ id: 3, name: "נעליים", price: 299 },
];
return (
<CartProvider>
<div>
<h1>חנות</h1>
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<CartSummary />
</div>
</CartProvider>
);
}
מתי להשתמש בקונטקסט ומתי בהעברת Props¶
קונטקסט מתאים כש:¶
- הנתונים דרושים לקומפוננטות רבות בעומקים שונים
- הנתונים משתנים לעיתים רחוקות (ערכת נושא, שפה, משתמש מחובר)
- רוצים להימנע מ-prop drilling עמוק
העברת Props עדיפה כש:¶
- הנתונים דרושים רק לרמה אחת או שתיים מתחת
- יש קומפוננטות מעטות שצריכות את הנתונים
- רוצים שהקומפוננטות יהיו גמישות ו-reusable
חשוב לדעת¶
- שינוי בערך הקונטקסט גורם לרנדר מחדש של כל הקומפוננטות שצורכות אותו
- אם הקונטקסט מכיל נתונים שמשתנים תדיר, עדיף לפצל אותו למספר קונטקסטים
// עדיף - קונטקסטים נפרדים
<ThemeProvider>
<AuthProvider>
<CartProvider>
<App />
</CartProvider>
</AuthProvider>
</ThemeProvider>
// פחות טוב - קונטקסט אחד ענק
<AppProvider> {/* מכיל theme, auth, cart */}
<App />
</AppProvider>
useState מול useReducer¶
| מאפיין | useState | useReducer |
|---|---|---|
| מורכבות | ערך פשוט | state מורכב עם שדות רבים |
| עדכונים | ישיר | דרך actions מוגדרים |
| לוגיקה | פשוטה | מורכבת עם תנאים |
| בדיקות | קשה יותר | קל לבדוק את ה-reducer בנפרד |
| debugging | פחות מובנה | כל action מתועד |
סיכום¶
- קונטקסט פותר את בעיית ה-prop drilling על ידי מתן גישה ישירה לנתונים מכל עומק בעץ
- createContext יוצר קונטקסט, Provider מספק ערך, ו-useContext צורך אותו
- מומלץ ליצור הוק מותאם אישית שעוטף useContext עם בדיקת שגיאה
- useReducer מתאים לניהול state מורכב עם actions מוגדרים
- שילוב של Context + useReducer מספק מנגנון ניהול state גלובלי בסגנון Redux
- חשוב לא להפריז בשימוש בקונטקסט - לפעמים העברת props פשוטה היא הפתרון הנכון