7.3 קונטקסט ו useReducer פתרון
פתרון - קונטקסט ו-useReducer - Context and useReducer¶
פתרון תרגיל 1 - קונטקסט ערכת נושא מתקדם¶
import { createContext, useContext, useState, useEffect } from "react";
type ThemeMode = "light" | "dark" | "system";
interface ThemeColors {
background: string;
text: string;
primary: string;
secondary: string;
border: string;
cardBg: string;
}
interface ThemeContextType {
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
colors: ThemeColors;
isDark: boolean;
}
const lightColors: ThemeColors = {
background: "#ffffff",
text: "#1a1a1a",
primary: "#3b82f6",
secondary: "#6b7280",
border: "#e5e7eb",
cardBg: "#f9fafb",
};
const darkColors: ThemeColors = {
background: "#1a1a2e",
text: "#e5e5e5",
primary: "#60a5fa",
secondary: "#9ca3af",
border: "#374151",
cardBg: "#16213e",
};
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 [themeMode, setThemeMode] = useState<ThemeMode>(() => {
const saved = localStorage.getItem("themeMode");
return (saved as ThemeMode) || "system";
});
const [systemDark, setSystemDark] = useState(
window.matchMedia("(prefers-color-scheme: dark)").matches
);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
useEffect(() => {
localStorage.setItem("themeMode", themeMode);
}, [themeMode]);
const isDark =
themeMode === "dark" || (themeMode === "system" && systemDark);
const colors = isDark ? darkColors : lightColors;
return (
<ThemeContext.Provider value={{ themeMode, setThemeMode, colors, isDark }}>
{children}
</ThemeContext.Provider>
);
}
// קומפוננטות
function ThemedButton({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) {
const { colors } = useTheme();
return (
<button
onClick={onClick}
style={{
backgroundColor: colors.primary,
color: "#fff",
border: "none",
padding: "8px 16px",
borderRadius: "6px",
cursor: "pointer",
}}
>
{children}
</button>
);
}
function ThemedCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const { colors } = useTheme();
return (
<div
style={{
backgroundColor: colors.cardBg,
border: `1px solid ${colors.border}`,
borderRadius: "8px",
padding: "16px",
color: colors.text,
}}
>
<h3>{title}</h3>
{children}
</div>
);
}
function SettingsPage() {
const { themeMode, setThemeMode, isDark } = useTheme();
return (
<ThemedCard title="הגדרות תצוגה">
<p>ערכת נושא נוכחית: {isDark ? "כהה" : "בהיר"}</p>
<div>
{(["light", "dark", "system"] as ThemeMode[]).map((mode) => (
<label key={mode} style={{ marginLeft: "15px" }}>
<input
type="radio"
name="theme"
checked={themeMode === mode}
onChange={() => setThemeMode(mode)}
/>
{mode === "light" ? "בהיר" : mode === "dark" ? "כהה" : "מערכת"}
</label>
))}
</div>
</ThemedCard>
);
}
הסבר:
- ההגדרה "מערכת" משתמשת ב-matchMedia כדי לזהות את העדפת המשתמש
- ה-listener על שינויים מאפשר תגובה בזמן אמת כשהמשתמש משנה את הגדרות המערכת
- כל הקומפוננטות משתמשות ב-colors מהקונטקסט ומתעדכנות אוטומטית
פתרון תרגיל 2 - ניהול התראות עם useReducer¶
import { createContext, useContext, useReducer, useCallback, useEffect, useRef } from "react";
interface Notification {
id: string;
type: "success" | "error" | "warning" | "info";
message: string;
duration: number;
}
type NotificationAction =
| { type: "add"; payload: Notification }
| { type: "remove"; payload: string }
| { type: "clear" };
function notificationReducer(
state: Notification[],
action: NotificationAction
): Notification[] {
switch (action.type) {
case "add":
return [...state, action.payload];
case "remove":
return state.filter((n) => n.id !== action.payload);
case "clear":
return [];
default:
return state;
}
}
interface NotificationContextType {
notifications: Notification[];
addNotification: (
type: Notification["type"],
message: string,
duration?: number
) => void;
removeNotification: (id: string) => void;
clearAll: () => void;
}
const NotificationContext = createContext<NotificationContextType | null>(null);
function useNotifications() {
const context = useContext(NotificationContext);
if (!context)
throw new Error("useNotifications must be used within NotificationProvider");
return context;
}
function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, dispatch] = useReducer(notificationReducer, []);
const addNotification = useCallback(
(type: Notification["type"], message: string, duration = 5000) => {
const id = Date.now().toString() + Math.random().toString(36).slice(2);
dispatch({ type: "add", payload: { id, type, message, duration } });
},
[]
);
const removeNotification = useCallback((id: string) => {
dispatch({ type: "remove", payload: id });
}, []);
const clearAll = useCallback(() => {
dispatch({ type: "clear" });
}, []);
return (
<NotificationContext.Provider
value={{ notifications, addNotification, removeNotification, clearAll }}
>
{children}
<NotificationContainer />
</NotificationContext.Provider>
);
}
function NotificationItem({ notification }: { notification: Notification }) {
const { removeNotification } = useNotifications();
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (notification.duration > 0) {
timerRef.current = window.setTimeout(() => {
removeNotification(notification.id);
}, notification.duration);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [notification.id, notification.duration, removeNotification]);
const colors = {
success: { bg: "#d4edda", border: "#28a745", text: "#155724" },
error: { bg: "#f8d7da", border: "#dc3545", text: "#721c24" },
warning: { bg: "#fff3cd", border: "#ffc107", text: "#856404" },
info: { bg: "#d1ecf1", border: "#17a2b8", text: "#0c5460" },
};
const style = colors[notification.type];
return (
<div
style={{
backgroundColor: style.bg,
borderLeft: `4px solid ${style.border}`,
color: style.text,
padding: "12px 16px",
marginBottom: "8px",
borderRadius: "4px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>{notification.message}</span>
<button
onClick={() => removeNotification(notification.id)}
style={{ background: "none", border: "none", cursor: "pointer" }}
>
X
</button>
</div>
);
}
function NotificationContainer() {
const { notifications } = useNotifications();
return (
<div
style={{
position: "fixed",
top: "20px",
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
width: "400px",
}}
>
{notifications.map((n) => (
<NotificationItem key={n.id} notification={n} />
))}
</div>
);
}
// שימוש
function DemoPage() {
const { addNotification, clearAll } = useNotifications();
return (
<div>
<h2>דוגמת התראות</h2>
<button onClick={() => addNotification("success", "הפעולה בוצעה בהצלחה!")}>
הצלחה
</button>
<button onClick={() => addNotification("error", "אירעה שגיאה!")}>
שגיאה
</button>
<button onClick={() => addNotification("warning", "שים לב!")}>
אזהרה
</button>
<button onClick={() => addNotification("info", "מידע חשוב")}>
מידע
</button>
<button onClick={clearAll}>נקה הכל</button>
</div>
);
}
הסבר:
- כל התראה מקבלת ID ייחודי כדי שנוכל להסיר אותה ספציפית
- ה-timer נשמר ב-useRef ומתנקה ב-cleanup של useEffect
- ה-NotificationContainer מרונדר בתוך ה-Provider כדי שיהיה תמיד זמין
פתרון תרגיל 3 - סל קניות מלא¶
import { createContext, useContext, useReducer, useEffect } from "react";
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
image: string;
}
interface CartState {
items: CartItem[];
total: number;
discount: number;
couponCode: string | null;
}
type CartAction =
| { type: "add_item"; payload: Omit<CartItem, "quantity"> }
| { type: "remove_item"; payload: number }
| { type: "update_quantity"; payload: { id: number; quantity: number } }
| { type: "apply_coupon"; payload: string }
| { type: "remove_coupon" }
| { type: "clear" }
| { type: "load"; payload: CartState };
function calculateTotal(items: CartItem[]) {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
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 items: CartItem[];
if (existing) {
items = state.items.map((i) =>
i.id === action.payload.id
? { ...i, quantity: i.quantity + 1 }
: i
);
} else {
items = [...state.items, { ...action.payload, quantity: 1 }];
}
const total = calculateTotal(items);
const discount = state.couponCode ? total * 0.1 : 0;
return { ...state, items, total, discount };
}
case "remove_item": {
const items = state.items.filter((i) => i.id !== action.payload);
const total = calculateTotal(items);
const discount = state.couponCode ? total * 0.1 : 0;
return { ...state, items, total, discount };
}
case "update_quantity": {
const items = state.items
.map((i) =>
i.id === action.payload.id
? { ...i, quantity: action.payload.quantity }
: i
)
.filter((i) => i.quantity > 0);
const total = calculateTotal(items);
const discount = state.couponCode ? total * 0.1 : 0;
return { ...state, items, total, discount };
}
case "apply_coupon": {
if (action.payload === "SAVE10") {
const discount = state.total * 0.1;
return { ...state, couponCode: action.payload, discount };
}
return state;
}
case "remove_coupon":
return { ...state, couponCode: null, discount: 0 };
case "clear":
return { items: [], total: 0, discount: 0, couponCode: null };
case "load":
return action.payload;
default:
return state;
}
}
const initialState: CartState = {
items: [],
total: 0,
discount: 0,
couponCode: null,
};
const CartContext = createContext<{
state: CartState;
dispatch: React.Dispatch<CartAction>;
} | 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, initialState, () => {
const saved = localStorage.getItem("cart");
return saved ? JSON.parse(saved) : initialState;
});
useEffect(() => {
localStorage.setItem("cart", JSON.stringify(state));
}, [state]);
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
// קומפוננטות
function CartBadge() {
const { state } = useCart();
const count = state.items.reduce((sum, i) => sum + i.quantity, 0);
return (
<span
style={{
backgroundColor: "red",
color: "white",
borderRadius: "50%",
padding: "2px 8px",
fontSize: "12px",
}}
>
{count}
</span>
);
}
function ProductGrid() {
const { dispatch } = useCart();
const products = [
{ id: 1, name: "טלפון", price: 2999, image: "/phone.jpg" },
{ id: 2, name: "אוזניות", price: 499, image: "/headphones.jpg" },
{ id: 3, name: "מטען", price: 99, image: "/charger.jpg" },
{ id: 4, name: "כיסוי", price: 49, image: "/case.jpg" },
];
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: "16px" }}>
{products.map((product) => (
<div key={product.id} style={{ border: "1px solid #ddd", padding: "16px" }}>
<h3>{product.name}</h3>
<p>{product.price} ש"ח</p>
<button onClick={() => dispatch({ type: "add_item", payload: product })}>
הוסף לסל
</button>
</div>
))}
</div>
);
}
function CartDrawer() {
const { state, dispatch } = useCart();
const [couponInput, setCouponInput] = useState("");
return (
<div style={{ border: "1px solid #ddd", padding: "16px" }}>
<h2>סל קניות <CartBadge /></h2>
{state.items.length === 0 ? (
<p>הסל ריק</p>
) : (
<>
{state.items.map((item) => (
<div key={item.id} style={{ borderBottom: "1px solid #eee", padding: "8px 0" }}>
<span>{item.name}</span>
<span> - {item.price} ש"ח</span>
<div>
<button
onClick={() =>
dispatch({
type: "update_quantity",
payload: { id: item.id, quantity: item.quantity - 1 },
})
}
>
-
</button>
<span style={{ margin: "0 8px" }}>{item.quantity}</span>
<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>סה"כ: {item.price * item.quantity} ש"ח</p>
</div>
))}
<div style={{ marginTop: "16px" }}>
<input
value={couponInput}
onChange={(e) => setCouponInput(e.target.value)}
placeholder="קוד קופון"
/>
<button
onClick={() => dispatch({ type: "apply_coupon", payload: couponInput })}
>
החל
</button>
</div>
<div style={{ marginTop: "16px" }}>
<p>סכום ביניים: {state.total} ש"ח</p>
{state.discount > 0 && (
<p style={{ color: "green" }}>
הנחה (10%): -{state.discount.toFixed(0)} ש"ח
<button onClick={() => dispatch({ type: "remove_coupon" })}>
הסר קופון
</button>
</p>
)}
<p style={{ fontWeight: "bold" }}>
סה"כ לתשלום: {(state.total - state.discount).toFixed(0)} ש"ח
</p>
</div>
<button onClick={() => dispatch({ type: "clear" })}>רוקן סל</button>
</>
)}
</div>
);
}
הסבר:
- הפרמטר השלישי של useReducer הוא initializer function שטוענת מ-localStorage
- כל שינוי ב-state מסונכרן ל-localStorage דרך useEffect
- חישוב ההנחה מתבצע בתוך ה-reducer כדי שה-state תמיד יהיה עקבי
פתרון תרגיל 4 - מערכת אותנטיקציה¶
import { createContext, useContext, useReducer, useEffect } from "react";
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
status: "idle" | "loading" | "authenticated" | "error";
user: User | null;
token: string | null;
error: string | null;
}
type AuthAction =
| { type: "auth_start" }
| { type: "auth_success"; payload: { user: User; token: string } }
| { type: "auth_error"; payload: string }
| { type: "logout" }
| { type: "update_profile"; payload: Partial<User> };
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "auth_start":
return { ...state, status: "loading", error: null };
case "auth_success":
return {
status: "authenticated",
user: action.payload.user,
token: action.payload.token,
error: null,
};
case "auth_error":
return { ...state, status: "error", error: action.payload };
case "logout":
return { status: "idle", user: null, token: null, error: null };
case "update_profile":
return {
...state,
user: state.user ? { ...state.user, ...action.payload } : null,
};
default:
return state;
}
}
interface AuthContextType {
state: AuthState;
login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => void;
updateProfile: (data: Partial<User>) => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth must be used within AuthProvider");
return context;
}
function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, {
status: "idle",
user: null,
token: null,
error: null,
});
// בדיקת טוקן בטעינה ראשונה
useEffect(() => {
const token = localStorage.getItem("authToken");
const userData = localStorage.getItem("userData");
if (token && userData) {
dispatch({
type: "auth_success",
payload: { user: JSON.parse(userData), token },
});
}
}, []);
// סנכרון ל-localStorage
useEffect(() => {
if (state.token) {
localStorage.setItem("authToken", state.token);
localStorage.setItem("userData", JSON.stringify(state.user));
} else {
localStorage.removeItem("authToken");
localStorage.removeItem("userData");
}
}, [state.token, state.user]);
const login = async (email: string, password: string) => {
dispatch({ type: "auth_start" });
try {
// סימולציה של קריאת API
await new Promise((resolve) => setTimeout(resolve, 1000));
if (email === "test@test.com" && password === "123456") {
dispatch({
type: "auth_success",
payload: {
user: { id: "1", name: "משתמש לדוגמה", email },
token: "fake-token-123",
},
});
} else {
throw new Error("אימייל או סיסמה שגויים");
}
} catch (err) {
dispatch({
type: "auth_error",
payload: err instanceof Error ? err.message : "שגיאה",
});
}
};
const register = async (name: string, email: string, password: string) => {
dispatch({ type: "auth_start" });
try {
await new Promise((resolve) => setTimeout(resolve, 1000));
dispatch({
type: "auth_success",
payload: {
user: { id: Date.now().toString(), name, email },
token: "fake-token-" + Date.now(),
},
});
} catch (err) {
dispatch({
type: "auth_error",
payload: err instanceof Error ? err.message : "שגיאה בהרשמה",
});
}
};
const logout = () => {
dispatch({ type: "logout" });
};
const updateProfile = (data: Partial<User>) => {
dispatch({ type: "update_profile", payload: data });
};
return (
<AuthContext.Provider value={{ state, login, register, logout, updateProfile }}>
{children}
</AuthContext.Provider>
);
}
// קומפוננטות
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { state } = useAuth();
if (state.status === "loading") return <p>טוען...</p>;
if (state.status !== "authenticated") return <LoginForm />;
return <>{children}</>;
}
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { login, state } = useAuth();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login(email, password);
};
return (
<form onSubmit={handleSubmit}>
<h2>התחברות</h2>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="אימייל"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="סיסמה"
/>
<button type="submit" disabled={state.status === "loading"}>
{state.status === "loading" ? "מתחבר..." : "התחבר"}
</button>
</form>
);
}
function ProfilePage() {
const { state, logout, updateProfile } = useAuth();
const [name, setName] = useState(state.user?.name ?? "");
return (
<div>
<h2>פרופיל</h2>
<p>אימייל: {state.user?.email}</p>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={() => updateProfile({ name })}>עדכן שם</button>
<button onClick={logout}>התנתק</button>
</div>
);
}
הסבר:
- ה-reducer מנהל מצב אותנטיקציה ברור עם ארבעה סטטוסים אפשריים
- בטעינה ראשונה בודקים אם יש טוקן שמור ומחברים אוטומטית
- ProtectedRoute מציגה את תוכן הילדים רק אם המשתמש מחובר, אחרת מציגה טופס התחברות
פתרון תרגיל 5 - מערכת רב-לשונית¶
import { createContext, useContext, useState, useEffect } from "react";
type Locale = "he" | "en" | "ar";
type Direction = "rtl" | "ltr";
const translations: Record<Locale, Record<string, string>> = {
he: {
"nav.home": "בית",
"nav.about": "אודות",
"nav.contact": "צרו קשר",
"welcome.title": "ברוכים הבאים",
"welcome.subtitle": "אתר לדוגמה עם תמיכה רב-לשונית",
"language.select": "בחר שפה",
"theme.toggle": "החלף ערכת נושא",
},
en: {
"nav.home": "Home",
"nav.about": "About",
"nav.contact": "Contact",
"welcome.title": "Welcome",
"welcome.subtitle": "A sample site with internationalization support",
"language.select": "Select language",
"theme.toggle": "Toggle theme",
},
ar: {
"nav.home": "الرئيسية",
"nav.about": "حول",
"nav.contact": "اتصل بنا",
"welcome.title": "مرحبا",
"welcome.subtitle": "موقع نموذجي مع دعم متعدد اللغات",
"language.select": "اختر اللغة",
"theme.toggle": "تبديل السمة",
},
};
const directions: Record<Locale, Direction> = {
he: "rtl",
en: "ltr",
ar: "rtl",
};
interface I18nContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
dir: Direction;
}
const I18nContext = createContext<I18nContextType | null>(null);
function useI18n() {
const context = useContext(I18nContext);
if (!context) throw new Error("useI18n must be used within I18nProvider");
return context;
}
function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocale] = useState<Locale>(() => {
const saved = localStorage.getItem("locale");
return (saved as Locale) || "he";
});
useEffect(() => {
localStorage.setItem("locale", locale);
document.documentElement.dir = directions[locale];
document.documentElement.lang = locale;
}, [locale]);
const t = (key: string): string => {
return translations[locale][key] || key;
};
return (
<I18nContext.Provider value={{ locale, setLocale, t, dir: directions[locale] }}>
{children}
</I18nContext.Provider>
);
}
// קומפוננטות
function LanguageSwitcher() {
const { locale, setLocale, t } = useI18n();
const languages: { code: Locale; label: string }[] = [
{ code: "he", label: "עברית" },
{ code: "en", label: "English" },
{ code: "ar", label: "العربية" },
];
return (
<div>
<label>{t("language.select")}: </label>
<select value={locale} onChange={(e) => setLocale(e.target.value as Locale)}>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
</div>
);
}
function Navigation() {
const { t } = useI18n();
return (
<nav>
<a href="#">{t("nav.home")}</a>
<a href="#">{t("nav.about")}</a>
<a href="#">{t("nav.contact")}</a>
</nav>
);
}
function WelcomePage() {
const { t, dir } = useI18n();
return (
<div dir={dir}>
<Navigation />
<h1>{t("welcome.title")}</h1>
<p>{t("welcome.subtitle")}</p>
<LanguageSwitcher />
</div>
);
}
הסבר:
- כל השפות מאוחסנות באובייקט translations מאורגן לפי שפה ומפתח
- הפונקציה t מחפשת את המפתח בשפה הנוכחית ומחזירה את המפתח עצמו כ-fallback
- בשינוי שפה, מעדכנים את dir ו-lang ב-document.documentElement
פתרון תרגיל 6 - מנהל משימות מתקדם¶
import { createContext, useContext, useReducer, useCallback } from "react";
type Status = "todo" | "in-progress" | "done";
type Priority = "low" | "medium" | "high";
interface Task {
id: string;
title: string;
description: string;
status: Status;
priority: Priority;
assignee: string;
dueDate: string;
}
interface TasksState {
tasks: Task[];
filter: {
status: Status | "all";
priority: Priority | "all";
assignee: string;
};
sortBy: "dueDate" | "priority" | "title";
lastDeleted: Task | null;
}
type TasksAction =
| { type: "add_task"; payload: Task }
| { type: "update_task"; payload: Task }
| { type: "delete_task"; payload: string }
| { type: "undo_delete" }
| { type: "change_status"; payload: { id: string; status: Status } }
| { type: "set_filter"; payload: Partial<TasksState["filter"]> }
| { type: "set_sort"; payload: TasksState["sortBy"] };
function tasksReducer(state: TasksState, action: TasksAction): TasksState {
switch (action.type) {
case "add_task":
return { ...state, tasks: [...state.tasks, action.payload] };
case "update_task":
return {
...state,
tasks: state.tasks.map((t) =>
t.id === action.payload.id ? action.payload : t
),
};
case "delete_task": {
const deleted = state.tasks.find((t) => t.id === action.payload);
return {
...state,
tasks: state.tasks.filter((t) => t.id !== action.payload),
lastDeleted: deleted || null,
};
}
case "undo_delete":
return state.lastDeleted
? {
...state,
tasks: [...state.tasks, state.lastDeleted],
lastDeleted: null,
}
: state;
case "change_status":
return {
...state,
tasks: state.tasks.map((t) =>
t.id === action.payload.id
? { ...t, status: action.payload.status }
: t
),
};
case "set_filter":
return {
...state,
filter: { ...state.filter, ...action.payload },
};
case "set_sort":
return { ...state, sortBy: action.payload };
default:
return state;
}
}
// שני קונטקסטים נפרדים
const TasksContext = createContext<TasksState | null>(null);
const TasksDispatchContext =
createContext<React.Dispatch<TasksAction> | null>(null);
function useTasks() {
const context = useContext(TasksContext);
if (!context) throw new Error("useTasks must be used within TasksProvider");
return context;
}
function useTasksDispatch() {
const context = useContext(TasksDispatchContext);
if (!context) throw new Error("useTasksDispatch must be used within TasksProvider");
return context;
}
function TasksProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(tasksReducer, {
tasks: [],
filter: { status: "all", priority: "all", assignee: "" },
sortBy: "dueDate",
lastDeleted: null,
});
return (
<TasksContext.Provider value={state}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
// תצוגת Kanban
function KanbanBoard() {
const { tasks, filter, sortBy } = useTasks();
const filteredTasks = tasks
.filter((t) => {
if (filter.status !== "all" && t.status !== filter.status) return false;
if (filter.priority !== "all" && t.priority !== filter.priority) return false;
if (filter.assignee && t.assignee !== filter.assignee) return false;
return true;
})
.sort((a, b) => {
switch (sortBy) {
case "dueDate":
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
case "priority": {
const order = { high: 0, medium: 1, low: 2 };
return order[a.priority] - order[b.priority];
}
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
const columns: { status: Status; title: string }[] = [
{ status: "todo", title: "לביצוע" },
{ status: "in-progress", title: "בתהליך" },
{ status: "done", title: "הושלם" },
];
return (
<div style={{ display: "flex", gap: "16px" }}>
{columns.map((col) => (
<KanbanColumn
key={col.status}
title={col.title}
status={col.status}
tasks={filteredTasks.filter((t) => t.status === col.status)}
/>
))}
</div>
);
}
function KanbanColumn({
title,
status,
tasks,
}: {
title: string;
status: Status;
tasks: Task[];
}) {
return (
<div
style={{
flex: 1,
backgroundColor: "#f5f5f5",
borderRadius: "8px",
padding: "12px",
}}
>
<h3>{title} ({tasks.length})</h3>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
);
}
function TaskCard({ task }: { task: Task }) {
const dispatch = useTasksDispatch();
const priorityColors = { low: "#4caf50", medium: "#ff9800", high: "#f44336" };
const nextStatus: Record<Status, Status> = {
todo: "in-progress",
"in-progress": "done",
done: "todo",
};
return (
<div
style={{
backgroundColor: "white",
borderRadius: "4px",
padding: "12px",
marginBottom: "8px",
borderRight: `4px solid ${priorityColors[task.priority]}`,
}}
>
<h4>{task.title}</h4>
<p>{task.description}</p>
<p>אחראי: {task.assignee}</p>
<p>תאריך יעד: {task.dueDate}</p>
<button
onClick={() =>
dispatch({
type: "change_status",
payload: { id: task.id, status: nextStatus[task.status] },
})
}
>
העבר
</button>
<button onClick={() => dispatch({ type: "delete_task", payload: task.id })}>
מחק
</button>
</div>
);
}
function UndoBar() {
const { lastDeleted } = useTasks();
const dispatch = useTasksDispatch();
if (!lastDeleted) return null;
return (
<div
style={{
position: "fixed",
bottom: "20px",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: "#333",
color: "white",
padding: "12px 24px",
borderRadius: "8px",
}}
>
<span>המשימה "{lastDeleted.title}" נמחקה</span>
<button
onClick={() => dispatch({ type: "undo_delete" })}
style={{ marginRight: "12px" }}
>
בטל מחיקה
</button>
</div>
);
}
הסبر:
- פיצלנו לשני קונטקסטים: אחד לנתונים ואחד ל-dispatch. כך, קומפוננטות שרק שולחות פעולות לא ירנדרו מחדש כשהנתונים משתנים
- פיצ'ר Undo שומר את המשימה האחרונה שנמחקה ומאפשר שחזור
- תצוגת Kanban מציגה שלוש עמודות, עם אפשרות להעביר משימות בין עמודות
תשובות לשאלות¶
-
רנדר מחדש בשינוי קונטקסט: כל קומפוננטה שקוראת ל-useContext עם אותו קונטקסט תעבור רנדר מחדש כשהערך של ה-Provider משתנה. קומפוננטות שלא צורכות את הקונטקסט לא יושפעו. חשוב לציין שגם ילדים של קומפוננטה שצורכת קונטקסט ירנדרו מחדש (כפי שקורה בכל רנדר).
-
פיצול קונטקסט: כשקונטקסט אחד מכיל נתונים רבים, כל שינוי בכל שדה גורם לרנדר מחדש של כל הצרכנים. בפיצול, שינוי בערכת נושא לא ירנדר מחדש קומפוננטות שצורכות רק אותנטיקציה, ולהפך. זה חיוני לביצועים טובים באפליקציות גדולות.
-
useReducer מול useState מרובה: useReducer מספק: מעברי state מרוכזים ואטומיים (שינוי של כמה שדות בבת אחת), action types שמתעדים את כוונת השינוי (קל ל-debug), reducer שניתן לבדיקה בנפרד (פונקציה טהורה), והפרדה ברורה בין "מה קרה" (action) ל"מה השתנה" (reducer).
-
dispatch ו-useCallback: הפונקציה dispatch שמוחזרת מ-useReducer כבר יציבה (stable) בין רנדרים - ריאקט מבטיחה שההפניה שלה לא משתנה. לכן, אין צורך לעטוף אותה ב-useCallback. זה יתרון נוסף של useReducer על פני useState.
-
מגבלות Context + useReducer: המגבלות העיקריות: כל שינוי ב-context מרנדר את כל הצרכנים (אין selectivity כמו ב-Zustand), אין middleware מובנה (persist, devtools), קשה לנהל state אסינכרוני (fetching), וב-context מקונן (נסטד) ביצועים יורדים. פתרונות כמו Zustand מספקים selectors יעילים, middleware, ו-API פשוט יותר.