לדלג לתוכן

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 צפויים ומאורגנים

תחביר בסיסי

const [state, dispatch] = useReducer(reducer, initialState);
  • reducer - פונקציה שמקבלת state נוכחי ו-action, ומחזירה state חדש
  • initialState - הערך ההתחלתי של ה-state
  • state - ה-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 פשוטה היא הפתרון הנכון