Patterns
Design patterns are proven solutions to recurring problems in software design. In React, patterns help you organize components, share logic, manage complexity, and build flexible, maintainable APIs. Understanding these patterns lets you choose the right abstraction for each situation.
The patterns evolved alongside React itself. Class component patterns (HOCs, render props) have been largely replaced by hooks-based patterns (custom hooks, compound components). This guide covers both — legacy patterns you'll encounter in existing codebases and modern patterns you should use in new projects.
The custom hooks pattern is the primary way to share stateful logic in modern React. It extracts reusable logic into standalone functions that can be composed together. Custom hooks are the most important pattern to master.
| 1 | // Custom hook: encapsulates complex logic |
| 2 | function useOnlineStatus() { |
| 3 | const [isOnline, setIsOnline] = useState(navigator.onLine); |
| 4 | |
| 5 | useEffect(() => { |
| 6 | const goOnline = () => setIsOnline(true); |
| 7 | const goOffline = () => setIsOnline(false); |
| 8 | |
| 9 | window.addEventListener("online", goOnline); |
| 10 | window.addEventListener("offline", goOffline); |
| 11 | |
| 12 | return () => { |
| 13 | window.removeEventListener("online", goOnline); |
| 14 | window.removeEventListener("offline", goOffline); |
| 15 | }; |
| 16 | }, []); |
| 17 | |
| 18 | return isOnline; |
| 19 | } |
| 20 | |
| 21 | // Custom hook: API call with loading/error states |
| 22 | function useApi(url) { |
| 23 | const [state, setState] = useState({ |
| 24 | data: null, |
| 25 | loading: true, |
| 26 | error: null, |
| 27 | }); |
| 28 | |
| 29 | useEffect(() => { |
| 30 | let cancelled = false; |
| 31 | setState(prev => ({ ...prev, loading: true })); |
| 32 | |
| 33 | fetch(url) |
| 34 | .then(res => { |
| 35 | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 36 | return res.json(); |
| 37 | }) |
| 38 | .then(data => { |
| 39 | if (!cancelled) setState({ data, loading: false, error: null }); |
| 40 | }) |
| 41 | .catch(error => { |
| 42 | if (!cancelled) setState({ data: null, loading: false, error }); |
| 43 | }); |
| 44 | |
| 45 | return () => { cancelled = true; }; |
| 46 | }, [url]); |
| 47 | |
| 48 | return state; |
| 49 | } |
| 50 | |
| 51 | // Composing hooks together |
| 52 | function UserProfile({ userId }) { |
| 53 | const isOnline = useOnlineStatus(); |
| 54 | const { data: user, loading, error } = useApi(`/api/users/${userId}`); |
| 55 | |
| 56 | if (loading) return <Spinner />; |
| 57 | if (error) return <ErrorMessage error={error} />; |
| 58 | |
| 59 | return ( |
| 60 | <div> |
| 61 | <h2>{user.name}</h2> |
| 62 | {isOnline && <span className="green-dot" />} |
| 63 | <p>{user.bio}</p> |
| 64 | </div> |
| 65 | ); |
| 66 | } |
| 67 | |
| 68 | // Complex hook: form with validation |
| 69 | function useLoginForm() { |
| 70 | const [values, setValues] = useState({ email: "", password: "" }); |
| 71 | const [errors, setErrors] = useState({}); |
| 72 | const [isSubmitting, setIsSubmitting] = useState(false); |
| 73 | |
| 74 | const validate = () => { |
| 75 | const newErrors = {}; |
| 76 | if (!values.email) newErrors.email = "Required"; |
| 77 | else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) { |
| 78 | newErrors.email = "Invalid email"; |
| 79 | } |
| 80 | if (!values.password) newErrors.password = "Required"; |
| 81 | else if (values.password.length < 8) newErrors.password = "Too short"; |
| 82 | setErrors(newErrors); |
| 83 | return Object.keys(newErrors).length === 0; |
| 84 | }; |
| 85 | |
| 86 | const handleChange = (e) => { |
| 87 | const { name, value } = e.target; |
| 88 | setValues(prev => ({ ...prev, [name]: value })); |
| 89 | if (errors[name]) setErrors(prev => ({ ...prev, [name]: undefined })); |
| 90 | }; |
| 91 | |
| 92 | const handleSubmit = async (onSubmit) => async (e) => { |
| 93 | e.preventDefault(); |
| 94 | if (!validate()) return; |
| 95 | setIsSubmitting(true); |
| 96 | try { |
| 97 | await onSubmit(values); |
| 98 | } finally { |
| 99 | setIsSubmitting(false); |
| 100 | } |
| 101 | }; |
| 102 | |
| 103 | return { values, errors, isSubmitting, handleChange, handleSubmit }; |
| 104 | } |
pro tip
Compound components share implicit state through Context, creating an expressive, declarative API. The parent manages state and children access it through Context — consumers don't need to pass props manually.
| 1 | import { createContext, useContext, useState } from "react"; |
| 2 | |
| 3 | // Step 1: Create context |
| 4 | const TabsContext = createContext(null); |
| 5 | |
| 6 | // Step 2: Parent component manages state |
| 7 | function Tabs({ children, defaultValue }) { |
| 8 | const [activeTab, setActiveTab] = useState(defaultValue); |
| 9 | |
| 10 | return ( |
| 11 | <TabsContext.Provider value={{ activeTab, setActiveTab }}> |
| 12 | <div className="tabs">{children}</div> |
| 13 | </TabsContext.Provider> |
| 14 | ); |
| 15 | } |
| 16 | |
| 17 | // Step 3: Child components use context |
| 18 | Tabs.List = function TabsList({ children }) { |
| 19 | return <div className="tab-list" role="tablist">{children}</div>; |
| 20 | }; |
| 21 | |
| 22 | Tabs.Tab = function Tab({ value, children }) { |
| 23 | const { activeTab, setActiveTab } = useContext(TabsContext); |
| 24 | const isActive = activeTab === value; |
| 25 | |
| 26 | return ( |
| 27 | <button |
| 28 | role="tab" |
| 29 | aria-selected={isActive} |
| 30 | className={`tab ${isActive ? "active" : ""}`} |
| 31 | onClick={() => setActiveTab(value)} |
| 32 | > |
| 33 | {children} |
| 34 | </button> |
| 35 | ); |
| 36 | }; |
| 37 | |
| 38 | Tabs.Panel = function TabPanel({ value, children }) { |
| 39 | const { activeTab } = useContext(TabsContext); |
| 40 | |
| 41 | if (activeTab !== value) return null; |
| 42 | |
| 43 | return ( |
| 44 | <div role="tabpanel" className="tab-panel"> |
| 45 | {children} |
| 46 | </div> |
| 47 | ); |
| 48 | }; |
| 49 | |
| 50 | // Step 4: Clean, declarative usage |
| 51 | function App() { |
| 52 | return ( |
| 53 | <Tabs defaultValue="overview"> |
| 54 | <Tabs.List> |
| 55 | <Tabs.Tab value="overview">Overview</Tabs.Tab> |
| 56 | <Tabs.Tab value="details">Details</Tabs.Tab> |
| 57 | <Tabs.Tab value="reviews">Reviews</Tabs.Tab> |
| 58 | </Tabs.List> |
| 59 | <Tabs.Panel value="overview">Overview content here</Tabs.Panel> |
| 60 | <Tabs.Panel value="details">Details content here</Tabs.Panel> |
| 61 | <Tabs.Panel value="reviews">Reviews content here</Tabs.Panel> |
| 62 | </Tabs> |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | // Another example: Disclosure (accordion) |
| 67 | const DisclosureContext = createContext(null); |
| 68 | |
| 69 | function Disclosure({ children, defaultOpen = false }) { |
| 70 | const [isOpen, setIsOpen] = useState(defaultOpen); |
| 71 | const toggle = () => setIsOpen(prev => !prev); |
| 72 | |
| 73 | return ( |
| 74 | <DisclosureContext.Provider value={{ isOpen, toggle }}> |
| 75 | <div className="disclosure">{children}</div> |
| 76 | </DisclosureContext.Provider> |
| 77 | ); |
| 78 | } |
| 79 | |
| 80 | Disclosure.Button = function DisclosureButton({ children }) { |
| 81 | const { isOpen, toggle } = useContext(DisclosureContext); |
| 82 | return ( |
| 83 | <button onClick={toggle} aria-expanded={isOpen}> |
| 84 | {children} |
| 85 | </button> |
| 86 | ); |
| 87 | }; |
| 88 | |
| 89 | Disclosure.Panel = function DisclosurePanel({ children }) { |
| 90 | const { isOpen } = useContext(DisclosureContext); |
| 91 | return isOpen ? <div className="panel">{children}</div> : null; |
| 92 | }; |
best practice
The provider pattern wraps the component tree with context to provide shared state, services, or configuration. It's the foundation for themes, authentication, localization, and state management.
| 1 | import { createContext, useContext, useState, useEffect } from "react"; |
| 2 | |
| 3 | // Auth provider — manages authentication state |
| 4 | const AuthContext = createContext(null); |
| 5 | |
| 6 | export function AuthProvider({ children }) { |
| 7 | const [user, setUser] = useState(null); |
| 8 | const [loading, setLoading] = useState(true); |
| 9 | |
| 10 | useEffect(() => { |
| 11 | // Check for existing session on mount |
| 12 | const session = localStorage.getItem("session"); |
| 13 | if (session) { |
| 14 | setUser(JSON.parse(session)); |
| 15 | } |
| 16 | setLoading(false); |
| 17 | }, []); |
| 18 | |
| 19 | const login = async (email, password) => { |
| 20 | const response = await fetch("/api/login", { |
| 21 | method: "POST", |
| 22 | headers: { "Content-Type": "application/json" }, |
| 23 | body: JSON.stringify({ email, password }), |
| 24 | }); |
| 25 | if (!response.ok) throw new Error("Login failed"); |
| 26 | const { user, token } = await response.json(); |
| 27 | localStorage.setItem("session", JSON.stringify(user)); |
| 28 | localStorage.setItem("token", token); |
| 29 | setUser(user); |
| 30 | }; |
| 31 | |
| 32 | const logout = () => { |
| 33 | localStorage.removeItem("session"); |
| 34 | localStorage.removeItem("token"); |
| 35 | setUser(null); |
| 36 | }; |
| 37 | |
| 38 | const value = { |
| 39 | user, |
| 40 | isAuthenticated: !!user, |
| 41 | isLoading: loading, |
| 42 | login, |
| 43 | logout, |
| 44 | }; |
| 45 | |
| 46 | return ( |
| 47 | <AuthContext.Provider value={value}> |
| 48 | {children} |
| 49 | </AuthContext.Provider> |
| 50 | ); |
| 51 | } |
| 52 | |
| 53 | // Custom hook for consuming context |
| 54 | export function useAuth() { |
| 55 | const context = useContext(AuthContext); |
| 56 | if (!context) throw new Error("useAuth must be used within AuthProvider"); |
| 57 | return context; |
| 58 | } |
| 59 | |
| 60 | // Theme provider with nested context |
| 61 | const ThemeContext = createContext(null); |
| 62 | const LocaleContext = createContext(null); |
| 63 | |
| 64 | export function AppProviders({ children }) { |
| 65 | return ( |
| 66 | <AuthProvider> |
| 67 | <ThemeProvider> |
| 68 | <LocaleProvider> |
| 69 | {children} |
| 70 | </LocaleProvider> |
| 71 | </ThemeProvider> |
| 72 | </AuthProvider> |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | // Composition in App.tsx |
| 77 | function App() { |
| 78 | return ( |
| 79 | <AppProviders> |
| 80 | <BrowserRouter> |
| 81 | <AppRoutes /> |
| 82 | </BrowserRouter> |
| 83 | </AppProviders> |
| 84 | ); |
| 85 | } |
| 86 | |
| 87 | // Consuming providers in components |
| 88 | function Header() { |
| 89 | const { user, logout } = useAuth(); |
| 90 | const { theme, toggleTheme } = useTheme(); |
| 91 | const { locale, setLocale } = useLocale(); |
| 92 | |
| 93 | return ( |
| 94 | <header> |
| 95 | {user && <span>Welcome, {user.name}</span>} |
| 96 | <button onClick={toggleTheme}>{theme}</button> |
| 97 | <select value={locale} onChange={e => setLocale(e.target.value)}> |
| 98 | <option value="en">English</option> |
| 99 | <option value="es">Spanish</option> |
| 100 | </select> |
| 101 | {user && <button onClick={logout}>Logout</button>} |
| 102 | </header> |
| 103 | ); |
| 104 | } |
info
A HOC is a function that takes a component and returns a new component with enhanced behavior. HOCs were the primary pattern for code reuse before hooks. They're still found in libraries like Redux (connect) and React Router.
| 1 | import { Component } from "react"; |
| 2 | |
| 3 | // HOC: adds loading state to any component |
| 4 | function withLoading(WrappedComponent) { |
| 5 | return function WithLoadingComponent({ isLoading, ...props }) { |
| 6 | if (isLoading) { |
| 7 | return <div className="spinner">Loading...</div>; |
| 8 | } |
| 9 | return <WrappedComponent {...props} />; |
| 10 | }; |
| 11 | } |
| 12 | |
| 13 | // HOC: adds authentication check |
| 14 | function withAuth(WrappedComponent) { |
| 15 | return function WithAuthComponent(props) { |
| 16 | const { isAuthenticated, isLoading } = useAuth(); |
| 17 | |
| 18 | if (isLoading) return <Spinner />; |
| 19 | if (!isAuthenticated) return <Navigate to="/login" />; |
| 20 | |
| 21 | return <WrappedComponent {...props} />; |
| 22 | }; |
| 23 | } |
| 24 | |
| 25 | // HOC: adds error boundary |
| 26 | function withErrorBoundary(WrappedComponent, FallbackComponent) { |
| 27 | return class WithErrorBoundary extends Component { |
| 28 | state = { hasError: false, error: null }; |
| 29 | |
| 30 | static getDerivedStateFromError(error) { |
| 31 | return { hasError: true, error }; |
| 32 | } |
| 33 | |
| 34 | render() { |
| 35 | if (this.state.hasError) { |
| 36 | return <FallbackComponent error={this.state.error} />; |
| 37 | } |
| 38 | return <WrappedComponent {...this.props} />; |
| 39 | } |
| 40 | }; |
| 41 | } |
| 42 | |
| 43 | // HOC: adds logging |
| 44 | function withLogging(WrappedComponent, componentName) { |
| 45 | return function WithLoggingComponent(props) { |
| 46 | useEffect(() => { |
| 47 | console.log(`${componentName} mounted`, props); |
| 48 | return () => console.log(`${componentName} unmounted`); |
| 49 | }, []); |
| 50 | |
| 51 | return <WrappedComponent {...props} />; |
| 52 | }; |
| 53 | } |
| 54 | |
| 55 | // Usage — composing HOCs |
| 56 | const EnhancedDashboard = withErrorBoundary( |
| 57 | withAuth( |
| 58 | withLoading(Dashboard) |
| 59 | ), |
| 60 | ErrorFallback |
| 61 | ); |
| 62 | |
| 63 | // HOC with data fetching |
| 64 | function withUserData(WrappedComponent) { |
| 65 | return function WithUserData(props) { |
| 66 | const { data: user, loading, error } = useFetch("/api/me"); |
| 67 | |
| 68 | if (loading) return <Spinner />; |
| 69 | if (error) return <ErrorMessage error={error} />; |
| 70 | |
| 71 | return <WrappedComponent user={user} {...props} />; |
| 72 | }; |
| 73 | } |
| 74 | |
| 75 | const UserDashboard = withUserData(Dashboard); |
note
The render props pattern passes a function as a prop that the component calls to determine what to render. It gives parent components control over rendering while the child manages behavior. Largely replaced by custom hooks.
| 1 | // Render prop: manages mouse position |
| 2 | function MouseTracker({ render }) { |
| 3 | const [position, setPosition] = useState({ x: 0, y: 0 }); |
| 4 | |
| 5 | useEffect(() => { |
| 6 | const handler = (e) => setPosition({ x: e.clientX, y: e.clientY }); |
| 7 | window.addEventListener("mousemove", handler); |
| 8 | return () => window.removeEventListener("mousemove", handler); |
| 9 | }, []); |
| 10 | |
| 11 | return render(position); |
| 12 | } |
| 13 | |
| 14 | // Usage |
| 15 | <MouseTracker render={({ x, y }) => <p>Mouse: {x}, {y}</p>} /> |
| 16 | |
| 17 | // Render prop: manages window size |
| 18 | function WindowSize({ render }) { |
| 19 | const [size, setSize] = useState({ |
| 20 | width: window.innerWidth, |
| 21 | height: window.innerHeight, |
| 22 | }); |
| 23 | |
| 24 | useEffect(() => { |
| 25 | const handler = () => setSize({ |
| 26 | width: window.innerWidth, |
| 27 | height: window.innerHeight, |
| 28 | }); |
| 29 | window.addEventListener("resize", handler); |
| 30 | return () => window.removeEventListener("resize", handler); |
| 31 | }, []); |
| 32 | |
| 33 | return render(size); |
| 34 | } |
| 35 | |
| 36 | // Render prop: manages toggle state |
| 37 | function Toggle({ initial = false, onToggle, children }) { |
| 38 | const [on, setOn] = useState(initial); |
| 39 | |
| 40 | const toggle = () => { |
| 41 | const next = !on; |
| 42 | setOn(next); |
| 43 | onToggle?.(next); |
| 44 | }; |
| 45 | |
| 46 | return children({ on, toggle }); |
| 47 | } |
| 48 | |
| 49 | // Usage — flexible rendering |
| 50 | <Toggle initial={false} onToggle={console.log}> |
| 51 | {({ on, toggle }) => ( |
| 52 | <div> |
| 53 | <button onClick={toggle}>{on ? "ON" : "OFF"}</button> |
| 54 | {on && <ExpensiveContent />} |
| 55 | </div> |
| 56 | )} |
| 57 | </Toggle> |
| 58 | |
| 59 | // Render prop: manages fetch state |
| 60 | function Fetch({ url, children }) { |
| 61 | const { data, loading, error } = useFetch(url); |
| 62 | return children({ data, loading, error }); |
| 63 | } |
| 64 | |
| 65 | // Usage |
| 66 | <Fetch url="/api/products"> |
| 67 | {({ data, loading, error }) => { |
| 68 | if (loading) return <Spinner />; |
| 69 | if (error) return <Error error={error} />; |
| 70 | return <ProductList products={data} />; |
| 71 | }} |
| 72 | </Fetch> |
note
The controlled vs uncontrolled pattern determines who owns the state — the parent (controlled) or the component itself (uncontrolled). This applies to inputs, modals, dropdowns, and any stateful component.
| 1 | // Controlled component — parent owns the state |
| 2 | function ControlledInput({ value, onChange, label }) { |
| 3 | return ( |
| 4 | <div> |
| 5 | <label>{label}</label> |
| 6 | <input value={value} onChange={e => onChange(e.target.value)} /> |
| 7 | </div> |
| 8 | ); |
| 9 | } |
| 10 | |
| 11 | // Uncontrolled component — component owns the state |
| 12 | function UncontrolledInput({ defaultValue = "", label }) { |
| 13 | const [value, setValue] = useState(defaultValue); |
| 14 | return ( |
| 15 | <div> |
| 16 | <label>{label}</label> |
| 17 | <input value={value} onChange={e => setValue(e.target.value)} /> |
| 18 | </div> |
| 19 | ); |
| 20 | } |
| 21 | |
| 22 | // Flexible component supporting both patterns |
| 23 | function FlexibleInput({ |
| 24 | value, // controlled |
| 25 | defaultValue, // uncontrolled |
| 26 | onChange, |
| 27 | label, |
| 28 | }) { |
| 29 | const [internalValue, setInternalValue] = useState(defaultValue || ""); |
| 30 | const isControlled = value !== undefined; |
| 31 | const currentValue = isControlled ? value : internalValue; |
| 32 | |
| 33 | const handleChange = (e) => { |
| 34 | const newValue = e.target.value; |
| 35 | if (!isControlled) setInternalValue(newValue); |
| 36 | onChange?.(e); |
| 37 | }; |
| 38 | |
| 39 | return ( |
| 40 | <div> |
| 41 | <label>{label}</label> |
| 42 | <input value={currentValue} onChange={handleChange} /> |
| 43 | </div> |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | // Usage |
| 48 | // Controlled |
| 49 | <FlexibleInput value={name} onChange={setName} label="Name" /> |
| 50 | |
| 51 | // Uncontrolled |
| 52 | <FlexibleInput defaultValue="hello" label="Name" /> |
| 53 | |
| 54 | // Modal with controlled/uncontrolled pattern |
| 55 | function Modal({ open, defaultOpen, onClose, children }) { |
| 56 | const [internalOpen, setInternalOpen] = useState(defaultOpen || false); |
| 57 | const isOpen = open !== undefined ? open : internalOpen; |
| 58 | |
| 59 | const handleClose = () => { |
| 60 | if (open === undefined) setInternalOpen(false); |
| 61 | onClose?.(); |
| 62 | }; |
| 63 | |
| 64 | return isOpen ? ( |
| 65 | <div className="modal-overlay" onClick={handleClose}> |
| 66 | <div className="modal" onClick={e => e.stopPropagation()}> |
| 67 | {children} |
| 68 | <button onClick={handleClose}>Close</button> |
| 69 | </div> |
| 70 | </div> |
| 71 | ) : null; |
| 72 | } |
pro tip
The layout pattern provides a consistent structure across pages. The slot pattern (named children) lets consumers fill specific areas of a layout. Both are fundamental to building maintainable UIs.
| 1 | // Layout pattern — shared structure |
| 2 | function PageLayout({ children, sidebar }) { |
| 3 | return ( |
| 4 | <div className="flex min-h-screen"> |
| 5 | {sidebar && <aside className="w-64">{sidebar}</aside>} |
| 6 | <main className="flex-1">{children}</main> |
| 7 | </div> |
| 8 | ); |
| 9 | } |
| 10 | |
| 11 | // Slot pattern — named content areas |
| 12 | function Card({ header, children, footer, actions }) { |
| 13 | return ( |
| 14 | <div className="card"> |
| 15 | {header && <div className="card-header">{header}</div>} |
| 16 | <div className="card-body">{children}</div> |
| 17 | {actions && <div className="card-actions">{actions}</div>} |
| 18 | {footer && <div className="card-footer">{footer}</div>} |
| 19 | </div> |
| 20 | ); |
| 21 | } |
| 22 | |
| 23 | // Usage |
| 24 | <Card |
| 25 | header={<h2>User Profile</h2>} |
| 26 | actions={<Button>Edit</Button>} |
| 27 | footer={<p className="text-sm text-gray-400">Last updated: today</p>} |
| 28 | > |
| 29 | <p>Name: Jane Doe</p> |
| 30 | <p>Email: jane@example.com</p> |
| 31 | </Card> |
| 32 | |
| 33 | // Slot pattern: compound layout |
| 34 | function DashboardLayout({ header, sidebar, content, footer }) { |
| 35 | return ( |
| 36 | <div className="min-h-screen flex flex-col"> |
| 37 | <header className="h-16 border-b">{header}</header> |
| 38 | <div className="flex flex-1"> |
| 39 | <aside className="w-64 border-r">{sidebar}</aside> |
| 40 | <main className="flex-1 p-6">{content}</main> |
| 41 | </div> |
| 42 | <footer className="h-12 border-t">{footer}</footer> |
| 43 | </div> |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | // Flexible component with render props for slots |
| 48 | function DataCard({ title, data, renderHeader, renderBody, renderFooter }) { |
| 49 | return ( |
| 50 | <div className="data-card"> |
| 51 | <div className="card-header"> |
| 52 | {renderHeader ? renderHeader(data) : <h3>{title}</h3>} |
| 53 | </div> |
| 54 | <div className="card-body"> |
| 55 | {renderBody ? renderBody(data) : <pre>{JSON.stringify(data, null, 2)}</pre>} |
| 56 | </div> |
| 57 | {renderFooter && <div className="card-footer">{renderFooter(data)}</div>} |
| 58 | </div> |
| 59 | ); |
| 60 | } |
| 61 | |
| 62 | // Usage |
| 63 | <DataCard |
| 64 | title="User Data" |
| 65 | data={user} |
| 66 | renderHeader={(data) => <h3>{data.name}</h3>} |
| 67 | renderBody={(data) => ( |
| 68 | <div> |
| 69 | <p>Email: {data.email}</p> |
| 70 | <p>Role: {data.role}</p> |
| 71 | </div> |
| 72 | )} |
| 73 | renderFooter={(data) => <span>Joined {data.joinDate}</span>} |
| 74 | /> |
State machines eliminate impossible states by defining explicit states and transitions. They make complex state logic predictable and debuggable. Libraries like XState formalize this pattern.
| 1 | // Simple state machine without XState |
| 2 | const fetchStates = { |
| 3 | idle: { |
| 4 | FETCH: "loading", |
| 5 | }, |
| 6 | loading: { |
| 7 | SUCCESS: "success", |
| 8 | FAILURE: "error", |
| 9 | }, |
| 10 | success: { |
| 11 | FETCH: "loading", |
| 12 | RESET: "idle", |
| 13 | }, |
| 14 | error: { |
| 15 | RETRY: "loading", |
| 16 | RESET: "idle", |
| 17 | }, |
| 18 | }; |
| 19 | |
| 20 | function useFetchMachine(url) { |
| 21 | const [state, setState] = useState("idle"); |
| 22 | const [data, setData] = useState(null); |
| 23 | const [error, setError] = useState(null); |
| 24 | |
| 25 | const send = (event) => { |
| 26 | const nextState = fetchStates[state]?.[event]; |
| 27 | if (!nextState) return; |
| 28 | |
| 29 | setState(nextState); |
| 30 | |
| 31 | if (event === "FETCH") { |
| 32 | fetch(url) |
| 33 | .then(res => res.json()) |
| 34 | .then(data => { |
| 35 | setData(data); |
| 36 | setState("success"); |
| 37 | }) |
| 38 | .catch(err => { |
| 39 | setError(err); |
| 40 | setState("error"); |
| 41 | }); |
| 42 | } |
| 43 | }; |
| 44 | |
| 45 | return { state, data, error, send }; |
| 46 | } |
| 47 | |
| 48 | // Usage |
| 49 | function DataFetcher({ url }) { |
| 50 | const { state, data, error, send } = useFetchMachine(url); |
| 51 | |
| 52 | return ( |
| 53 | <div> |
| 54 | {state === "idle" && <button onClick={() => send("FETCH")}>Load</button>} |
| 55 | {state === "loading" && <Spinner />} |
| 56 | {state === "success" && <DataView data={data} />} |
| 57 | {state === "error" && ( |
| 58 | <div> |
| 59 | <p>Error: {error.message}</p> |
| 60 | <button onClick={() => send("RETRY")}>Retry</button> |
| 61 | </div> |
| 62 | )} |
| 63 | </div> |
| 64 | ); |
| 65 | } |
| 66 | |
| 67 | // With XState (more powerful) |
| 68 | import { createMachine, assign } from "xstate"; |
| 69 | import { useMachine } from "@xstate/react"; |
| 70 | |
| 71 | const todoMachine = createMachine({ |
| 72 | id: "todos", |
| 73 | initial: "idle", |
| 74 | context: { todos: [], error: null }, |
| 75 | states: { |
| 76 | idle: { |
| 77 | on: { FETCH: "loading" }, |
| 78 | }, |
| 79 | loading: { |
| 80 | invoke: { |
| 81 | src: "fetchTodos", |
| 82 | onDone: { target: "idle", actions: assign({ todos: (_, e) => e.data }) }, |
| 83 | onError: { target: "error", actions: assign({ error: (_, e) => e.data }) }, |
| 84 | }, |
| 85 | }, |
| 86 | error: { |
| 87 | on: { RETRY: "loading" }, |
| 88 | }, |
| 89 | }, |
| 90 | }); |
React embraces composition over inheritance. Instead of extending components, compose them. This leads to more flexible, testable, and reusable code.
| 1 | // ❌ Bad: inheritance approach (doesn't work in React) |
| 2 | class BaseButton extends React.Component { |
| 3 | render() { |
| 4 | return <button className="btn">{this.props.children}</button>; |
| 5 | } |
| 6 | } |
| 7 | |
| 8 | class PrimaryButton extends BaseButton { // Can't extend function components! |
| 9 | render() { |
| 10 | return <button className="btn btn-primary">{this.props.children}</button>; |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | // ✅ Good: composition approach |
| 15 | function Button({ variant = "default", size = "md", children, ...props }) { |
| 16 | const classes = `btn btn-${variant} btn-${size}`; |
| 17 | return <button className={classes} {...props}>{children}</button>; |
| 18 | } |
| 19 | |
| 20 | // ✅ Good: wrapper pattern for extending behavior |
| 21 | function withIcon(IconComponent) { |
| 22 | return function ButtonWithIcon({ iconPosition = "left", children, ...props }) { |
| 23 | return ( |
| 24 | <Button {...props}> |
| 25 | {iconPosition === "left" && <IconComponent />} |
| 26 | {children} |
| 27 | {iconPosition === "right" && <IconComponent />} |
| 28 | </Button> |
| 29 | ); |
| 30 | }; |
| 31 | } |
| 32 | |
| 33 | const SearchButton = withIcon(SearchIcon); |
| 34 | |
| 35 | // ✅ Good: composition for complex components |
| 36 | function StatCard({ icon, label, value, trend, trendValue }) { |
| 37 | return ( |
| 38 | <Card> |
| 39 | <CardHeader> |
| 40 | <Icon name={icon} /> |
| 41 | <span>{label}</span> |
| 42 | </CardHeader> |
| 43 | <CardBody> |
| 44 | <StatValue value={value} /> |
| 45 | <TrendIndicator trend={trend} value={trendValue} /> |
| 46 | </CardBody> |
| 47 | </Card> |
| 48 | ); |
| 49 | } |
| 50 | |
| 51 | // ✅ Good: render delegation |
| 52 | function List({ items, renderItem, renderEmpty, keyExtractor }) { |
| 53 | if (items.length === 0) { |
| 54 | return renderEmpty ? renderEmpty() : <p>No items</p>; |
| 55 | } |
| 56 | |
| 57 | return ( |
| 58 | <ul> |
| 59 | {items.map((item, index) => ( |
| 60 | <li key={keyExtractor ? keyExtractor(item) : index}> |
| 61 | {renderItem(item, index)} |
| 62 | </li> |
| 63 | ))} |
| 64 | </ul> |
| 65 | ); |
| 66 | } |
best practice