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¶
הספרייה מספקת מימוש מוכן ונוח יותר:
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 importSuspenseמציג 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, כך ששגיאות בטעינה נתפסות