לדלג לתוכן

7.5 Error Boundaries, Portals ו Suspense הרצאה

Error Boundaries, Portals ו-Suspense

בשיעור הזה נלמד על שלושה מנגנונים מתקדמים בריאקט: Error Boundaries לטיפול בשגיאות, Portals לרנדור מחוץ לעץ ה-DOM הרגיל, ו-Suspense לטעינה עצלה של קומפוננטות.


גבולות שגיאה - Error Boundaries

הבעיה

כשקומפוננטה זורקת שגיאה בזמן רנדור, כל עץ הקומפוננטות קורס והמשתמש רואה מסך לבן. בלי טיפול מתאים, שגיאה בקומפוננטה קטנה הורסת את כל האפליקציה.

מה זה Error Boundary?

  • Error Boundary הוא קומפוננטה שתופסת שגיאות JavaScript בקומפוננטות ילד שלה
  • במקום לקרוס, היא מציגה ממשק חלופי (fallback UI)
  • זה עובד רק עם קומפוננטות מחלקה (class components) - אין הוק שמקביל

מימוש בסיסי

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

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

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

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

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

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("Error caught by boundary:", error, errorInfo);
    // כאן אפשר לשלוח את השגיאה לשירות מעקב
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback || (
          <div style={{ padding: "20px", textAlign: "center" }}>
            <h2>משהו השתבש</h2>
            <p>{this.state.error?.message}</p>
            <button
              onClick={() => this.setState({ hasError: false, error: null })}
            >
              נסה שוב
            </button>
          </div>
        )
      );
    }

    return this.props.children;
  }
}

שימוש:

function App() {
  return (
    <div>
      <h1>האפליקציה שלי</h1>
      <ErrorBoundary fallback={<p>שגיאה בטעינת הסרגל</p>}>
        <Sidebar />
      </ErrorBoundary>
      <ErrorBoundary fallback={<p>שגיאה בטעינת התוכן</p>}>
        <MainContent />
      </ErrorBoundary>
    </div>
  );
}
  • מומלץ לעטוף אזורים שונים של האפליקציה ב-Error Boundaries נפרדים
  • ככה שגיאה באזור אחד לא משפיעה על אזורים אחרים

מה Error Boundary לא תופס?

  • שגיאות ב-event handlers (צריך try/catch רגיל)
  • שגיאות בקוד אסינכרוני (setTimeout, fetch)
  • שגיאות בצד השרת (SSR)
  • שגיאות ב-Error Boundary עצמו

הספרייה react-error-boundary

הספרייה מספקת מימוש מוכן ונוח יותר:

npm install react-error-boundary
import { ErrorBoundary } from "react-error-boundary";

function ErrorFallback({
  error,
  resetErrorBoundary,
}: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" style={{ padding: "20px", backgroundColor: "#fee2e2" }}>
      <h2>שגיאה</h2>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>נסה שוב</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => {
        // שליחה לשירות מעקב שגיאות
        console.error("Logged error:", error, info);
      }}
      onReset={() => {
        // איפוס state אם צריך
      }}
    >
      <MyApp />
    </ErrorBoundary>
  );
}

שימוש עם useErrorBoundary

import { useErrorBoundary } from "react-error-boundary";

function DataLoader() {
  const { showBoundary } = useErrorBoundary();

  const loadData = async () => {
    try {
      const response = await fetch("/api/data");
      if (!response.ok) throw new Error("Failed to fetch");
      // ...
    } catch (error) {
      showBoundary(error);
    }
  };

  return <button onClick={loadData}>טען נתונים</button>;
}
  • useErrorBoundary מאפשר "לזרוק" שגיאות ל-Error Boundary מתוך event handlers וקוד אסינכרוני

פורטלים - Portals

מה זה Portal?

  • Portal מאפשר לרנדר קומפוננטה ילד לאלמנט DOM שנמצא מחוץ להיררכיה של הקומפוננטה ההורה
  • שימושי למודלים (modals), tooltips, dropdowns ותפריטים שצריכים "לברוח" מה-overflow של ההורה

תחביר

import { createPortal } from "react-dom";

function Modal({
  isOpen,
  onClose,
  children,
}: {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  if (!isOpen) return null;

  return createPortal(
    <div
      style={{
        position: "fixed",
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        backgroundColor: "rgba(0, 0, 0, 0.5)",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        zIndex: 1000,
      }}
      onClick={onClose}
    >
      <div
        style={{
          backgroundColor: "white",
          padding: "24px",
          borderRadius: "8px",
          maxWidth: "500px",
          width: "90%",
        }}
        onClick={(e) => e.stopPropagation()}
      >
        <button
          onClick={onClose}
          style={{ float: "left", border: "none", background: "none", fontSize: "18px", cursor: "pointer" }}
        >
          X
        </button>
        {children}
      </div>
    </div>,
    document.body
  );
}

שימוש:

function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <div style={{ overflow: "hidden", height: "200px" }}>
      <h1>תוכן עם overflow hidden</h1>
      <button onClick={() => setIsModalOpen(true)}>פתח מודל</button>

      <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
        <h2>כותרת המודל</h2>
        <p>תוכן המודל - למרות ש-overflow hidden, המודל מופיע מעל הכל</p>
      </Modal>
    </div>
  );
}
  • למרות שהמודל מוגדר בתוך div עם overflow: hidden, הוא מרונדר ב-document.body
  • אירועים עדיין עולים (bubble) דרך עץ הריאקט, לא דרך ה-DOM

דוגמה - Tooltip

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

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

  useEffect(() => {
    if (show && triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      setPosition({
        top: rect.top - 30,
        left: rect.left + rect.width / 2,
      });
    }
  }, [show]);

  return (
    <>
      <span
        ref={triggerRef}
        onMouseEnter={() => setShow(true)}
        onMouseLeave={() => setShow(false)}
      >
        {children}
      </span>

      {show &&
        createPortal(
          <div
            style={{
              position: "fixed",
              top: position.top,
              left: position.left,
              transform: "translateX(-50%)",
              backgroundColor: "#333",
              color: "white",
              padding: "4px 8px",
              borderRadius: "4px",
              fontSize: "12px",
              whiteSpace: "nowrap",
              zIndex: 9999,
            }}
          >
            {text}
          </div>,
          document.body
        )}
    </>
  );
}

דוגמה - Dropdown

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

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

  useEffect(() => {
    if (isOpen && triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      setPosition({
        top: rect.bottom + 4,
        left: rect.left,
      });
    }
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    const handleClick = () => setIsOpen(false);
    document.addEventListener("click", handleClick);
    return () => document.removeEventListener("click", handleClick);
  }, [isOpen]);

  return (
    <>
      <div
        ref={triggerRef}
        onClick={() => setIsOpen(!isOpen)}
        style={{ display: "inline-block", cursor: "pointer" }}
      >
        {trigger}
      </div>

      {isOpen &&
        createPortal(
          <ul
            style={{
              position: "fixed",
              top: position.top,
              left: position.left,
              backgroundColor: "white",
              border: "1px solid #ddd",
              borderRadius: "4px",
              listStyle: "none",
              padding: "4px 0",
              margin: 0,
              boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
              zIndex: 1000,
            }}
          >
            {items.map((item, i) => (
              <li
                key={i}
                onClick={item.onClick}
                style={{
                  padding: "8px 16px",
                  cursor: "pointer",
                }}
              >
                {item.label}
              </li>
            ))}
          </ul>,
          document.body
        )}
    </>
  );
}

Suspense וטעינה עצלה - Lazy Loading

הבעיה

באפליקציות גדולות, כל הקוד נארז לקובץ JavaScript אחד (bundle). ככל שהאפליקציה גדלה, הקובץ מתנפח וזמן הטעינה הראשונה גדל.

פיצול קוד - Code Splitting

React.lazy ו-Suspense מאפשרים לפצל את ה-bundle ולטעון קומפוננטות רק כשצריך אותן:

import { lazy, Suspense } from "react";

// במקום import רגיל:
// import Dashboard from "./pages/Dashboard";

// טעינה עצלה:
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const Analytics = lazy(() => import("./pages/Analytics"));

שימוש עם Suspense

import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";

const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const Analytics = lazy(() => import("./pages/Analytics"));

function LoadingSpinner() {
  return (
    <div style={{ display: "flex", justifyContent: "center", padding: "40px" }}>
      <p>טוען...</p>
    </div>
  );
}

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}
  • lazy() מקבל פונקציה שמחזירה dynamic import
  • Suspense מציג fallback בזמן שהקומפוננטה נטענת
  • הקומפוננטה נטענת רק כשמנסים לרנדר אותה לראשונה

Suspense מקונן

function App() {
  return (
    <Suspense fallback={<FullPageLoader />}>
      <Header />
      <Suspense fallback={<ContentSkeleton />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
      <Footer />
    </Suspense>
  );
}
  • אפשר לקנן Suspense ברמות שונות
  • ה-Suspense הקרוב ביותר לקומפוננטה שנטענת יציג את ה-fallback שלו

אסטרטגיות פיצול קוד

פיצול לפי נתיב (Route-based splitting) - הנפוץ ביותר:

const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));
const Dashboard = lazy(() => import("./pages/Dashboard"));

טעינה מוקדמת (Preloading) - טוענים לפני שצריך:

const Dashboard = lazy(() => import("./pages/Dashboard"));

function NavLink() {
  const preload = () => {
    // כשהמשתמש מרחף על הלינק, מתחילים לטעון
    import("./pages/Dashboard");
  };

  return (
    <Link to="/dashboard" onMouseEnter={preload}>
      לוח בקרה
    </Link>
  );
}

פיצול לפי תכונה (Feature-based splitting):

const HeavyChart = lazy(() => import("./components/HeavyChart"));
const PDFViewer = lazy(() => import("./components/PDFViewer"));

function Report({ showChart }: { showChart: boolean }) {
  return (
    <div>
      <h1>דוח</h1>
      {showChart && (
        <Suspense fallback={<p>טוען גרף...</p>}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
}

שילוב כל המנגנונים

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

const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));

function ErrorFallback({ error, resetErrorBoundary }: any) {
  return createPortal(
    <div
      style={{
        position: "fixed",
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        backgroundColor: "rgba(0,0,0,0.5)",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        zIndex: 9999,
      }}
    >
      <div style={{ backgroundColor: "white", padding: "24px", borderRadius: "8px" }}>
        <h2>שגיאה בלתי צפויה</h2>
        <p>{error.message}</p>
        <button onClick={resetErrorBoundary}>נסה שוב</button>
      </div>
    </div>,
    document.body
  );
}

function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <Suspense
          fallback={
            <div style={{ textAlign: "center", padding: "40px" }}>
              <p>טוען את האפליקציה...</p>
            </div>
          }
        >
          <Routes>
            <Route path="/dashboard" element={<Dashboard />} />
            <Route
              path="/settings"
              element={
                <ErrorBoundary
                  FallbackComponent={ErrorFallback}
                  resetKeys={["settings"]}
                >
                  <Settings />
                </ErrorBoundary>
              }
            />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
}

סיכום

  • Error Boundaries תופסים שגיאות בקומפוננטות ילד ומציגים fallback UI במקום לקרוס
  • ניתן להשתמש בספריית react-error-boundary למימוש נוח יותר עם useErrorBoundary
  • Portals מאפשרים לרנדר קומפוננטה מחוץ להיררכיית ה-DOM הרגילה - שימושי למודלים, tooltips ו-dropdowns
  • React.lazy ו-Suspense מאפשרים פיצול קוד וטעינה עצלה של קומפוננטות
  • פיצול לפי נתיבים (routes) הוא האסטרטגיה הנפוצה ביותר
  • אפשר לשלב את שלושת המנגנונים יחד לאפליקציה עמידה ומהירה
  • Error Boundary עוטף את Suspense, כך ששגיאות בטעינה נתפסות