לדלג לתוכן

7.5 Error Boundaries, Portals ו Suspense פתרון

פתרון - Error Boundaries, Portals ו-Suspense


פתרון תרגיל 1 - Error Boundary מותאם אישית

import { Component, ErrorInfo, ReactNode, useState } from "react";

interface Props {
  children: ReactNode;
  name?: string;
}

interface State {
  hasError: boolean;
  error: Error | null;
  errorInfo: ErrorInfo | null;
  showDetails: boolean;
}

class CustomErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = {
      hasError: false,
      error: null,
      errorInfo: null,
      showDetails: false,
    };
  }

  static getDerivedStateFromError(error: Error): Partial<State> {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    this.setState({ errorInfo });
    console.error(
      `[ErrorBoundary${this.props.name ? ` - ${this.props.name}` : ""}]`,
      {
        error: error.message,
        stack: error.stack,
        componentStack: errorInfo.componentStack,
        timestamp: new Date().toISOString(),
      }
    );
  }

  handleReset = () => {
    this.setState({
      hasError: false,
      error: null,
      errorInfo: null,
      showDetails: false,
    });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div
          style={{
            padding: "20px",
            margin: "10px",
            border: "1px solid #f44336",
            borderRadius: "8px",
            backgroundColor: "#fff5f5",
          }}
        >
          <h3>שגיאה {this.props.name ? `ב-${this.props.name}` : ""}</h3>
          <p>אירעה שגיאה בלתי צפויה. אנא נסו שוב.</p>
          <div style={{ display: "flex", gap: "8px" }}>
            <button onClick={this.handleReset}>נסה שוב</button>
            <button
              onClick={() =>
                this.setState((s) => ({ showDetails: !s.showDetails }))
              }
            >
              {this.state.showDetails ? "הסתר פרטים" : "צפה בפרטים"}
            </button>
          </div>
          {this.state.showDetails && (
            <pre
              style={{
                marginTop: "12px",
                padding: "12px",
                backgroundColor: "#f0f0f0",
                overflow: "auto",
                fontSize: "12px",
              }}
            >
              {this.state.error?.message}
              {"\n\n"}
              {this.state.error?.stack}
              {"\n\nComponent Stack:"}
              {this.state.errorInfo?.componentStack}
            </pre>
          )}
        </div>
      );
    }

    return this.props.children;
  }
}

// קומפוננטות שזורקות שגיאות
function BuggyCounter() {
  const [count, setCount] = useState(0);
  if (count === 3) throw new Error("המונה הגיע ל-3!");
  return (
    <div>
      <p>מונה: {count}</p>
      <button onClick={() => setCount(count + 1)}>הגדל (קורס ב-3)</button>
    </div>
  );
}

function BuggyRenderer() {
  const data: any = null;
  return <p>{data.nonExistent}</p>;
}

// שימוש
function App() {
  return (
    <div>
      <h1>דוגמת Error Boundary</h1>
      <CustomErrorBoundary name="מונה">
        <BuggyCounter />
      </CustomErrorBoundary>
      <CustomErrorBoundary name="רנדרר">
        <BuggyRenderer />
      </CustomErrorBoundary>
      <p>התוכן הזה ממשיך לעבוד גם כשקומפוננטות אחרות נפלו</p>
    </div>
  );
}

הסבר:
- כל קומפוננטה עטופה ב-Error Boundary נפרד עם שם ייחודי
- כפתור "צפה בפרטים" מתקפל/מתפתח ומציג stack trace מלא
- הלוג כולל timestamp, שם ה-boundary, ו-component stack


פתרון תרגיל 2 - מערכת מודלים עם Portal

import { createPortal } from "react-dom";
import { useState, useEffect, useCallback, useRef } from "react";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title?: string;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const [isAnimating, setIsAnimating] = useState(false);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (isOpen) {
      setIsVisible(true);
      requestAnimationFrame(() => setIsAnimating(true));
      document.body.style.overflow = "hidden";
    } else {
      setIsAnimating(false);
      const timer = setTimeout(() => setIsVisible(false), 300);
      document.body.style.overflow = "";
      return () => clearTimeout(timer);
    }

    return () => {
      document.body.style.overflow = "";
    };
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", handleEscape);
    return () => document.removeEventListener("keydown", handleEscape);
  }, [isOpen, onClose]);

  if (!isVisible) return null;

  return createPortal(
    <div
      style={{
        position: "fixed",
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        backgroundColor: `rgba(0, 0, 0, ${isAnimating ? 0.5 : 0})`,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        zIndex: 1000,
        transition: "background-color 0.3s ease",
      }}
      onClick={onClose}
    >
      <div
        style={{
          backgroundColor: "white",
          borderRadius: "12px",
          padding: "24px",
          maxWidth: "500px",
          width: "90%",
          maxHeight: "80vh",
          overflow: "auto",
          transform: isAnimating ? "scale(1)" : "scale(0.9)",
          opacity: isAnimating ? 1 : 0,
          transition: "transform 0.3s ease, opacity 0.3s ease",
        }}
        onClick={(e) => e.stopPropagation()}
      >
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            marginBottom: "16px",
          }}
        >
          {title && <h2 style={{ margin: 0 }}>{title}</h2>}
          <button
            onClick={onClose}
            style={{
              border: "none",
              background: "none",
              fontSize: "20px",
              cursor: "pointer",
            }}
          >
            X
          </button>
        </div>
        {children}
      </div>
    </div>,
    document.body
  );
}

function ConfirmDialog({
  isOpen,
  onConfirm,
  onCancel,
  title,
  message,
}: {
  isOpen: boolean;
  onConfirm: () => void;
  onCancel: () => void;
  title: string;
  message: string;
}) {
  return (
    <Modal isOpen={isOpen} onClose={onCancel} title={title}>
      <p>{message}</p>
      <div style={{ display: "flex", gap: "8px", justifyContent: "flex-end" }}>
        <button onClick={onCancel}>ביטול</button>
        <button
          onClick={onConfirm}
          style={{ backgroundColor: "#f44336", color: "white" }}
        >
          אישור
        </button>
      </div>
    </Modal>
  );
}

function AlertDialog({
  isOpen,
  onClose,
  title,
  message,
}: {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  message: string;
}) {
  return (
    <Modal isOpen={isOpen} onClose={onClose} title={title}>
      <p>{message}</p>
      <div style={{ textAlign: "left" }}>
        <button onClick={onClose}>הבנתי</button>
      </div>
    </Modal>
  );
}

// שימוש
function App() {
  const [showModal, setShowModal] = useState(false);
  const [showConfirm, setShowConfirm] = useState(false);
  const [showNested, setShowNested] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>פתח מודל</button>
      <button onClick={() => setShowConfirm(true)}>פתח אישור</button>

      <Modal isOpen={showModal} onClose={() => setShowModal(false)} title="מודל ראשי">
        <p>תוכן המודל</p>
        <button onClick={() => setShowNested(true)}>פתח מודל מקונן</button>

        <Modal isOpen={showNested} onClose={() => setShowNested(false)} title="מודל מקונן">
          <p>זה מודל בתוך מודל!</p>
        </Modal>
      </Modal>

      <ConfirmDialog
        isOpen={showConfirm}
        onConfirm={() => { alert("אושר!"); setShowConfirm(false); }}
        onCancel={() => setShowConfirm(false)}
        title="אישור מחיקה"
        message="האם אתה בטוח שברצונך למחוק?"
      />
    </div>
  );
}

הסبر:
- האנימציה מבוססת על שני מצבים: isVisible (האם ב-DOM) ו-isAnimating (האם מוצג)
- כשנסגר, קודם מתחילה אנימציית יציאה ורק אחרי 300ms מורידים מה-DOM
- body.style.overflow = "hidden" מונע גלילה ברקע
- מודלים מקוננים עובדים כי כל אחד הוא Portal נפרד


פתרון תרגיל 3 - Tooltip ו-Dropdown עם Portals

import { createPortal } from "react-dom";
import { useState, useRef, useEffect, useCallback } from "react";

type Position = "top" | "bottom" | "left" | "right";

function Tooltip({
  text,
  position = "top",
  children,
}: {
  text: string;
  position?: Position;
  children: React.ReactNode;
}) {
  const [show, setShow] = useState(false);
  const [coords, setCoords] = useState({ top: 0, left: 0 });
  const [actualPosition, setActualPosition] = useState(position);
  const triggerRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    if (!show || !triggerRef.current) return;

    const rect = triggerRef.current.getBoundingClientRect();
    let top = 0;
    let left = 0;
    let pos = position;

    // בדיקת מקום
    if (position === "top" && rect.top < 40) pos = "bottom";
    if (position === "bottom" && window.innerHeight - rect.bottom < 40) pos = "top";

    switch (pos) {
      case "top":
        top = rect.top - 8;
        left = rect.left + rect.width / 2;
        break;
      case "bottom":
        top = rect.bottom + 8;
        left = rect.left + rect.width / 2;
        break;
      case "left":
        top = rect.top + rect.height / 2;
        left = rect.left - 8;
        break;
      case "right":
        top = rect.top + rect.height / 2;
        left = rect.right + 8;
        break;
    }

    setCoords({ top, left });
    setActualPosition(pos);
  }, [show, position]);

  const tooltipStyle: React.CSSProperties = {
    position: "fixed",
    top: coords.top,
    left: coords.left,
    transform:
      actualPosition === "top"
        ? "translate(-50%, -100%)"
        : actualPosition === "bottom"
        ? "translate(-50%, 0)"
        : actualPosition === "left"
        ? "translate(-100%, -50%)"
        : "translate(0, -50%)",
    backgroundColor: "#333",
    color: "white",
    padding: "6px 10px",
    borderRadius: "4px",
    fontSize: "13px",
    whiteSpace: "nowrap",
    zIndex: 9999,
    pointerEvents: "none",
  };

  return (
    <>
      <span
        ref={triggerRef}
        onMouseEnter={() => setShow(true)}
        onMouseLeave={() => setShow(false)}
        style={{ display: "inline-block" }}
      >
        {children}
      </span>
      {show &&
        createPortal(<div style={tooltipStyle}>{text}</div>, document.body)}
    </>
  );
}

// Dropdown
interface MenuItem {
  label: string;
  onClick?: () => void;
  children?: MenuItem[];
}

function Dropdown({
  trigger,
  items,
}: {
  trigger: React.ReactNode;
  items: MenuItem[];
}) {
  const [isOpen, setIsOpen] = useState(false);
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen || !triggerRef.current) return;
    const rect = triggerRef.current.getBoundingClientRect();
    const spaceBelow = window.innerHeight - rect.bottom;

    setPosition({
      top: spaceBelow > 200 ? rect.bottom + 4 : rect.top - 4,
      left: rect.left,
    });
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    const handler = (e: MouseEvent) => {
      if (
        triggerRef.current &&
        !triggerRef.current.contains(e.target as Node)
      ) {
        setIsOpen(false);
      }
    };
    setTimeout(() => document.addEventListener("click", handler), 0);
    return () => document.removeEventListener("click", handler);
  }, [isOpen]);

  return (
    <>
      <div
        ref={triggerRef}
        onClick={() => setIsOpen(!isOpen)}
        style={{ display: "inline-block", cursor: "pointer" }}
      >
        {trigger}
      </div>
      {isOpen &&
        createPortal(
          <DropdownMenu items={items} position={position} onClose={() => setIsOpen(false)} />,
          document.body
        )}
    </>
  );
}

function DropdownMenu({
  items,
  position,
  onClose,
}: {
  items: MenuItem[];
  position: { top: number; left: number };
  onClose: () => void;
}) {
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
  const itemRefs = useRef<(HTMLLIElement | null)[]>([]);

  return (
    <ul
      style={{
        position: "fixed",
        top: position.top,
        left: position.left,
        backgroundColor: "white",
        border: "1px solid #ddd",
        borderRadius: "6px",
        listStyle: "none",
        padding: "4px 0",
        margin: 0,
        minWidth: "160px",
        boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
        zIndex: 1000,
      }}
    >
      {items.map((item, index) => (
        <li
          key={index}
          ref={(el) => { itemRefs.current[index] = el; }}
          onMouseEnter={() => setHoveredIndex(index)}
          onMouseLeave={() => setHoveredIndex(null)}
          onClick={() => {
            item.onClick?.();
            if (!item.children) onClose();
          }}
          style={{
            padding: "8px 16px",
            cursor: "pointer",
            backgroundColor: hoveredIndex === index ? "#f0f0f0" : "transparent",
            position: "relative",
          }}
        >
          {item.label}
          {item.children && " >"}
          {item.children && hoveredIndex === index && (
            <DropdownMenu
              items={item.children}
              position={{
                top: itemRefs.current[index]?.getBoundingClientRect().top ?? 0,
                left: (itemRefs.current[index]?.getBoundingClientRect().right ?? 0) + 4,
              }}
              onClose={onClose}
            />
          )}
        </li>
      ))}
    </ul>
  );
}

// שימוש
function App() {
  const menuItems: MenuItem[] = [
    { label: "ערוך", onClick: () => console.log("ערוך") },
    {
      label: "שתף",
      children: [
        { label: "אימייל", onClick: () => console.log("אימייל") },
        { label: "הודעה", onClick: () => console.log("הודעה") },
      ],
    },
    { label: "מחק", onClick: () => console.log("מחק") },
  ];

  return (
    <div style={{ overflow: "hidden", height: "100px" }}>
      <Tooltip text="זה טולטיפ!" position="top">
        <span>רחף עליי</span>
      </Tooltip>

      <Dropdown trigger={<button>תפריט</button>} items={menuItems} />
    </div>
  );
}

הסبر:
- ה-Tooltip מחשב מיקום דינמי ומשנה כיוון אם אין מקום
- ה-Dropdown תומך בתפריטי משנה מקוננים על ידי רנדור רקורסיבי
- שניהם עובדים נכון גם כשלהורה יש overflow: hidden


פתרון תרגיל 4 - טעינה עצלה עם Suspense

import { lazy, Suspense, useState } from "react";
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";

// טעינה עצלה
const HomePage = lazy(() => import("./pages/HomePage"));
const ProductsPage = lazy(() => import("./pages/ProductsPage"));
const AboutPage = lazy(() => import("./pages/AboutPage"));
const ContactPage = lazy(() => import("./pages/ContactPage"));

// מפת דפים לטעינה מוקדמת
const pageImports: Record<string, () => Promise<any>> = {
  "/": () => import("./pages/HomePage"),
  "/products": () => import("./pages/ProductsPage"),
  "/about": () => import("./pages/AboutPage"),
  "/contact": () => import("./pages/ContactPage"),
};

function PreloadLink({
  to,
  children,
}: {
  to: string;
  children: React.ReactNode;
}) {
  const handleMouseEnter = () => {
    const importFn = pageImports[to];
    if (importFn) importFn();
  };

  return (
    <NavLink
      to={to}
      onMouseEnter={handleMouseEnter}
      style={({ isActive }) => ({
        fontWeight: isActive ? "bold" : "normal",
        padding: "8px 16px",
      })}
    >
      {children}
    </NavLink>
  );
}

function SkeletonLoader() {
  return (
    <div style={{ padding: "20px" }}>
      <div
        style={{
          height: "32px",
          width: "60%",
          backgroundColor: "#e0e0e0",
          borderRadius: "4px",
          marginBottom: "16px",
          animation: "pulse 1.5s infinite",
        }}
      />
      {[1, 2, 3].map((i) => (
        <div
          key={i}
          style={{
            height: "16px",
            width: `${80 - i * 10}%`,
            backgroundColor: "#e0e0e0",
            borderRadius: "4px",
            marginBottom: "8px",
            animation: "pulse 1.5s infinite",
          }}
        />
      ))}
    </div>
  );
}

function PageErrorFallback({
  error,
  resetErrorBoundary,
}: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div style={{ padding: "20px", textAlign: "center" }}>
      <h2>שגיאה בטעינת הדף</h2>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>נסה שוב</button>
    </div>
  );
}

function App() {
  return (
    <BrowserRouter>
      <nav>
        <PreloadLink to="/">בית</PreloadLink>
        <PreloadLink to="/products">מוצרים</PreloadLink>
        <PreloadLink to="/about">אודות</PreloadLink>
        <PreloadLink to="/contact">צרו קשר</PreloadLink>
      </nav>

      <ErrorBoundary FallbackComponent={PageErrorFallback}>
        <Suspense fallback={<SkeletonLoader />}>
          <Routes>
            <Route path="/" element={<HomePage />} />
            <Route path="/products" element={<ProductsPage />} />
            <Route path="/about" element={<AboutPage />} />
            <Route path="/contact" element={<ContactPage />} />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
}

הסבר:
- PreloadLink מפעיל את ה-import כשהמשתמש מרחף על הלינק, כך שהטעינה מתחילה לפני הקליק
- SkeletonLoader מציג צורות אפורות שמדמות את המבנה של הדף
- Error Boundary עוטף את Suspense כדי לתפוס שגיאות טעינה


פתרון תרגיל 5 - מערכת התראות עם Portal

import { createPortal } from "react-dom";
import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";

type ToastType = "success" | "error" | "warning" | "info";
type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-left";

interface Toast {
  id: string;
  type: ToastType;
  message: string;
  duration: number;
}

interface ToastContextType {
  addToast: (type: ToastType, message: string, duration?: number) => void;
}

const ToastContext = createContext<ToastContextType | null>(null);

function useToast() {
  const context = useContext(ToastContext);
  if (!context) throw new Error("useToast must be used within ToastProvider");
  return context;
}

const MAX_TOASTS = 5;

function ToastProvider({
  children,
  position = "top-right",
}: {
  children: React.ReactNode;
  position?: ToastPosition;
}) {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const addToast = useCallback(
    (type: ToastType, message: string, duration = 5000) => {
      const id = Date.now().toString() + Math.random().toString(36).slice(2);
      setToasts((prev) => {
        const next = [...prev, { id, type, message, duration }];
        if (next.length > MAX_TOASTS) {
          return next.slice(next.length - MAX_TOASTS);
        }
        return next;
      });
    },
    []
  );

  const removeToast = useCallback((id: string) => {
    setToasts((prev) => prev.filter((t) => t.id !== id));
  }, []);

  const positionStyle: React.CSSProperties = {
    position: "fixed",
    zIndex: 9999,
    display: "flex",
    flexDirection: "column",
    gap: "8px",
    ...(position.includes("top") ? { top: "20px" } : { bottom: "20px" }),
    ...(position.includes("right") ? { right: "20px" } : { left: "20px" }),
  };

  return (
    <ToastContext.Provider value={{ addToast }}>
      {children}
      {createPortal(
        <div style={positionStyle}>
          {toasts.map((toast) => (
            <ToastItem
              key={toast.id}
              toast={toast}
              onRemove={removeToast}
              position={position}
            />
          ))}
        </div>,
        document.body
      )}
    </ToastContext.Provider>
  );
}

function ToastItem({
  toast,
  onRemove,
  position,
}: {
  toast: Toast;
  onRemove: (id: string) => void;
  position: ToastPosition;
}) {
  const [isExiting, setIsExiting] = useState(false);
  const timerRef = useRef<number | null>(null);

  useEffect(() => {
    if (toast.duration > 0) {
      timerRef.current = window.setTimeout(() => {
        setIsExiting(true);
        setTimeout(() => onRemove(toast.id), 300);
      }, toast.duration);
    }
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, [toast.id, toast.duration, onRemove]);

  const handleClose = () => {
    setIsExiting(true);
    setTimeout(() => onRemove(toast.id), 300);
  };

  const colors: Record<ToastType, { bg: string; border: string }> = {
    success: { bg: "#d4edda", border: "#28a745" },
    error: { bg: "#f8d7da", border: "#dc3545" },
    warning: { bg: "#fff3cd", border: "#ffc107" },
    info: { bg: "#d1ecf1", border: "#17a2b8" },
  };

  const slideDirection = position.includes("right") ? "20px" : "-20px";

  return (
    <div
      style={{
        backgroundColor: colors[toast.type].bg,
        borderRight: `4px solid ${colors[toast.type].border}`,
        padding: "12px 16px",
        borderRadius: "4px",
        minWidth: "280px",
        maxWidth: "400px",
        boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        transform: isExiting ? `translateX(${slideDirection})` : "translateX(0)",
        opacity: isExiting ? 0 : 1,
        transition: "transform 0.3s ease, opacity 0.3s ease",
        animation: "slideIn 0.3s ease",
      }}
    >
      <span>{toast.message}</span>
      <button
        onClick={handleClose}
        style={{
          border: "none",
          background: "none",
          cursor: "pointer",
          fontSize: "16px",
          marginRight: "8px",
        }}
      >
        X
      </button>
    </div>
  );
}

// שימוש
function DemoPage() {
  const { addToast } = useToast();

  return (
    <div>
      <h1>דוגמת התראות</h1>
      <button onClick={() => addToast("success", "הפעולה בוצעה בהצלחה!")}>
        הצלחה
      </button>
      <button onClick={() => addToast("error", "אירעה שגיאה בשמירה")}>
        שגיאה
      </button>
      <button onClick={() => addToast("warning", "שים לב - פעולה זו לא ניתנת לביטול")}>
        אזהרה
      </button>
      <button onClick={() => addToast("info", "עדכון חדש זמין")}>
        מידע
      </button>
    </div>
  );
}

function App() {
  return (
    <ToastProvider position="top-right">
      <DemoPage />
    </ToastProvider>
  );
}

הסבר:
- ההתראות מרונדרות ב-Portal לגוף הדף
- מקסימום 5 התראות - חדשה דוחפת את הישנה ביותר
- אנימציית כניסה (slideIn) ויציאה (fade+slide)
- כל התראה מתנקה אוטומטית אחרי ה-duration שלה


פתרון תרגיל 6 - אפליקציה מלאה עם שלושת המנגנונים

הפתרון משלב את כל המנגנונים שלמדנו. הנה המבנה העיקרי:

import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";

// טעינה עצלה
const StorePage = lazy(() => import("./pages/StorePage"));
const CartPage = lazy(() => import("./pages/CartPage"));
const CheckoutPage = lazy(() => import("./pages/CheckoutPage"));

function App() {
  return (
    <BrowserRouter>
      <ToastProvider>
        <CartProvider>
          <ErrorBoundary FallbackComponent={GlobalErrorFallback}>
            <Layout>
              <Suspense fallback={<PageSkeleton />}>
                <Routes>
                  <Route
                    path="/"
                    element={
                      <ErrorBoundary FallbackComponent={PageErrorFallback}>
                        <StorePage />
                      </ErrorBoundary>
                    }
                  />
                  <Route
                    path="/cart"
                    element={
                      <ErrorBoundary FallbackComponent={PageErrorFallback}>
                        <CartPage />
                      </ErrorBoundary>
                    }
                  />
                  <Route
                    path="/checkout"
                    element={
                      <ErrorBoundary FallbackComponent={PageErrorFallback}>
                        <CheckoutPage />
                      </ErrorBoundary>
                    }
                  />
                </Routes>
              </Suspense>
            </Layout>
          </ErrorBoundary>
        </CartProvider>
      </ToastProvider>
    </BrowserRouter>
  );
}

הסבר:
- שלושה שכבות: ToastProvider (Portals) > ErrorBoundary (שגיאות) > Suspense (טעינה)
- כל דף עטוף ב-ErrorBoundary נפרד כדי שנפילת דף אחד לא תפיל את האחרים
- Toasts משמשים להודעות הצלחה/שגיאה ב-CRUD operations
- מודלים (הוספה לסל, אישור הזמנה) מרונדרים כ-Portals


תשובות לשאלות

  1. Error Boundaries ומחלקות: Error Boundaries מוגבלים למחלקות כי הם מסתמכים על lifecycle methods (getDerivedStateFromError, componentDidCatch) שאין להם מקבילה בהוקים. ספריית react-error-boundary מספקת wrapper שמאפשר שימוש ב-ErrorBoundary כקומפוננטה רגילה, ו-useErrorBoundary מאפשר לזרוק שגיאות מקוד אסינכרוני.

  2. Event bubbling ב-Portals: למרות שב-DOM האלמנט נמצא במקום אחר (למשל ב-body), בריאקט אירועים ממשיכים לעלות דרך עץ הקומפוננטות המקורי. אז onClick על אלמנט בתוך Portal יעלה לקומפוננטת ההורה הריאקטית, לא להורה ב-DOM.

  3. מתי להשתמש ב-lazy: מומלץ לנתיבים (routes) של דפים שלמים, לקומפוננטות כבדות שלא תמיד נדרשות (כמו עורך קוד, גרף מורכב), ולתכונות שרק חלק מהמשתמשים ניגשים אליהם. לא מומלץ לקומפוננטות קטנות (ה-overhead של network request גדול מהחיסכון) או לתוכן שהמשתמש תמיד רואה (above the fold).

  4. סכנות Portals: שימוש יתר עלול לגרום לבעיות נגישות (a11y) כי קוראי מסך עשויים לא לזהות את הקשר בין ה-Portal לטריגר. כמו כן, ניהול focus, z-index ו-stacking context הופך מורכב. עדיף לא להשתמש ב-Portal כשאפשר לפתור את הבעיה עם CSS (למשל position: fixed).

  5. איך Suspense עובד: כש-React.lazy טוען קומפוננטה, הוא זורק Promise. Suspense תופס את ה-Promise הזה, מציג את ה-fallback, וכש-Promise מסתיים (הקומפוננטה נטענה), ריאקט מרנדר מחדש ומציג את הקומפוננטה. זה מנגנון דומה ל-Error Boundary, אבל עבור Promises במקום Errors.