Component Patterns
10 min read•Frontend Patterns & Concepts
Component Patterns
Well-designed components are the building blocks of maintainable applications. These patterns help you create flexible, reusable, and testable components.
Compound Components
Components that work together to form a complete UI, sharing implicit state.
JAVASCRIPT
// Usage - notice the declarative, flexible API
<Tabs>
<Tabs.List>
<Tabs.Tab>Account</Tabs.Tab>
<Tabs.Tab>Security</Tabs.Tab>
<Tabs.Tab>Notifications</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel>Account settings...</Tabs.Panel>
<Tabs.Panel>Security settings...</Tabs.Panel>
<Tabs.Panel>Notification preferences...</Tabs.Panel>
</Tabs.Panels>
</Tabs>Implementation
JAVASCRIPT
import {
createContext,
useContext,
useState,
Children,
cloneElement,
} from "react";
const TabsContext = createContext();
function Tabs({ children, defaultIndex = 0, onChange }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
const handleChange = (index) => {
setActiveIndex(index);
onChange?.(index);
};
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex: handleChange }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
const { activeIndex, setActiveIndex } = useContext(TabsContext);
return (
<div className="tab-list" role="tablist">
{Children.map(children, (child, index) =>
cloneElement(child, {
isActive: index === activeIndex,
onClick: () => setActiveIndex(index),
index,
}),
)}
</div>
);
}
function Tab({ children, isActive, onClick, index }) {
return (
<button
role="tab"
aria-selected={isActive}
className={`tab ${isActive ? "active" : ""}`}
onClick={onClick}
id={`tab-${index}`}
aria-controls={`panel-${index}`}
>
{children}
</button>
);
}
function TabPanels({ children }) {
const { activeIndex } = useContext(TabsContext);
return (
<div className="tab-panels">
{Children.map(children, (child, index) =>
cloneElement(child, {
isActive: index === activeIndex,
index,
}),
)}
</div>
);
}
function TabPanel({ children, isActive, index }) {
return (
<div
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={!isActive}
className="tab-panel"
>
{isActive && children}
</div>
);
}
// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
export default Tabs;More Complex Example: Accordion
JAVASCRIPT
const AccordionContext = createContext();
const ItemContext = createContext();
function Accordion({ children, allowMultiple = false, defaultOpen = [] }) {
const [openItems, setOpenItems] = useState(new Set(defaultOpen));
const toggle = (id) => {
setOpenItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!allowMultiple) next.clear();
next.add(id);
}
return next;
});
};
const isOpen = (id) => openItems.has(id);
return (
<AccordionContext.Provider value={{ toggle, isOpen }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
function Item({ children, id }) {
const { isOpen } = useContext(AccordionContext);
return (
<ItemContext.Provider value={{ id, isOpen: isOpen(id) }}>
<div className={`accordion-item ${isOpen(id) ? "open" : ""}`}>
{children}
</div>
</ItemContext.Provider>
);
}
function Header({ children }) {
const { toggle } = useContext(AccordionContext);
const { id, isOpen } = useContext(ItemContext);
return (
<button
className="accordion-header"
onClick={() => toggle(id)}
aria-expanded={isOpen}
>
{children}
<span className="icon">{isOpen ? "−" : "+"}</span>
</button>
);
}
function Content({ children }) {
const { isOpen } = useContext(ItemContext);
return (
<div
className="accordion-content"
style={{ display: isOpen ? "block" : "none" }}
>
{children}
</div>
);
}
Accordion.Item = Item;
Accordion.Header = Header;
Accordion.Content = Content;Render Props
Pass a function as a child or prop to share logic between components.
JAVASCRIPT
// The pattern
function DataFetcher({ url, children }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then((r) => r.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
// Call children as a function with state
return children({ data, loading, error });
}
// Usage
<DataFetcher url="/api/users">
{({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <UserList users={data} />;
}}
</DataFetcher>;Mouse Position Tracker
JAVASCRIPT
function MouseTracker({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener("mousemove", handleMove);
return () => window.removeEventListener("mousemove", handleMove);
}, []);
return children(position);
}
// Usage
<MouseTracker>
{({ x, y }) => (
<div style={{ position: "fixed", left: x + 10, top: y + 10 }}>
Tooltip follows mouse
</div>
)}
</MouseTracker>;Toggle with Render Props
JAVASCRIPT
function Toggle({ initial = false, children }) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
const setTrue = useCallback(() => setOn(true), []);
const setFalse = useCallback(() => setOn(false), []);
return children({
on,
off: !on,
toggle,
setTrue,
setFalse,
});
}
// Usage
<Toggle>
{({ on, toggle }) => (
<div>
<button onClick={toggle}>{on ? "Hide" : "Show"}</button>
{on && <Content />}
</div>
)}
</Toggle>;Higher-Order Components (HOCs)
Functions that take a component and return an enhanced component.
JAVASCRIPT
// Basic HOC pattern
function withLogger(WrappedComponent) {
return function LoggerComponent(props) {
useEffect(() => {
console.log(`${WrappedComponent.name} mounted with props:`, props);
return () => console.log(`${WrappedComponent.name} unmounted`);
}, []);
return <WrappedComponent {...props} />;
};
}
// Usage
const EnhancedButton = withLogger(Button);Practical HOC: withAuth
JAVASCRIPT
function withAuth(WrappedComponent, options = {}) {
const { redirectTo = "/login", requiredRole = null } = options;
return function AuthenticatedComponent(props) {
const { user, loading } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!loading && !user) {
navigate(redirectTo, { replace: true });
}
if (requiredRole && user?.role !== requiredRole) {
navigate("/unauthorized", { replace: true });
}
}, [user, loading, navigate]);
if (loading) return <LoadingScreen />;
if (!user) return null;
if (requiredRole && user.role !== requiredRole) return null;
return <WrappedComponent {...props} user={user} />;
};
}
// Usage
const AdminDashboard = withAuth(Dashboard, { requiredRole: "admin" });
const UserProfile = withAuth(Profile);Data Fetching HOC
JAVASCRIPT
function withData(WrappedComponent, fetchData) {
return function DataComponent(props) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(
() => {
let cancelled = false;
fetchData(props)
.then((data) => {
if (!cancelled) setData(data);
})
.catch((error) => {
if (!cancelled) setError(error);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
},
[
/* relevant props */
],
);
if (loading) return <Loading />;
if (error) return <Error error={error} />;
return <WrappedComponent {...props} data={data} />;
};
}
// Usage
const UserProfileWithData = withData(UserProfile, (props) =>
fetch(`/api/users/${props.userId}`).then((r) => r.json()),
);Custom Hooks: The Modern Alternative
Most HOC and render props patterns are better expressed as hooks.
JAVASCRIPT
// Instead of HOC
function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
authService
.getCurrentUser()
.then(setUser)
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (credentials) => {
const user = await authService.login(credentials);
setUser(user);
return user;
}, []);
const logout = useCallback(async () => {
await authService.logout();
setUser(null);
}, []);
return { user, loading, login, logout, isAuthenticated: !!user };
}
// Instead of render props mouse tracker
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return position;
}
// Usage is cleaner
function Tooltip({ children }) {
const { x, y } = useMousePosition();
return <div style={{ left: x, top: y }}>{children}</div>;
}Controlled vs Uncontrolled Components
Uncontrolled: Component manages its own state
JAVASCRIPT
function UncontrolledInput({ defaultValue, onSubmit }) {
const inputRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue={defaultValue} />
<button type="submit">Submit</button>
</form>
);
}
// Usage - no need to manage state
<UncontrolledInput
defaultValue="Hello"
onSubmit={(value) => console.log(value)}
/>;Controlled: Parent manages state
JAVASCRIPT
function ControlledInput({ value, onChange }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
// Usage - full control in parent
function Parent() {
const [value, setValue] = useState("");
return (
<div>
<ControlledInput value={value} onChange={setValue} />
<p>You typed: {value}</p>
<button onClick={() => setValue("")}>Clear</button>
</div>
);
}Hybrid: Support Both Modes
JAVASCRIPT
function FlexibleInput({
value: controlledValue,
defaultValue = '',
onChange
}) {
// Determine if controlled
const isControlled = controlledValue !== undefined;
// Internal state for uncontrolled mode
const [internalValue, setInternalValue] = useState(defaultValue);
// Use controlled or internal value
const value = isControlled ? controlledValue : internalValue;
const handleChange = (e) => {
const newValue = e.target.value;
if (!isControlled) {
setInternalValue(newValue);
}
onChange?.(newValue);
};
return <input value={value} onChange={handleChange} />;
}
// Can be used either way
<FlexibleInput defaultValue="uncontrolled" onChange={console.log} />
<FlexibleInput value={controlled} onChange={setControlled} />Provider Pattern
Inject dependencies via context for testability and flexibility.
JAVASCRIPT
// Theme provider
const ThemeContext = createContext({
theme: "light",
toggle: () => {},
});
function ThemeProvider({ children, initialTheme = "light" }) {
const [theme, setTheme] = useState(initialTheme);
const toggle = useCallback(() => {
setTheme((t) => (t === "light" ? "dark" : "light"));
}, []);
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
return (
<ThemeContext.Provider value={value}>
<div className={`theme-${theme}`}>{children}</div>
</ThemeContext.Provider>
);
}
const useTheme = () => useContext(ThemeContext);
// API client provider
const ApiContext = createContext();
function ApiProvider({ children, baseUrl }) {
const client = useMemo(
() => ({
get: (path) => fetch(`${baseUrl}${path}`).then((r) => r.json()),
post: (path, data) =>
fetch(`${baseUrl}${path}`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}).then((r) => r.json()),
}),
[baseUrl],
);
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>;
}
const useApi = () => useContext(ApiContext);Slot Pattern
Allow parent to inject content into specific locations.
JAVASCRIPT
function Card({ children }) {
let header, body, footer;
Children.forEach(children, (child) => {
if (child?.type === CardHeader) header = child;
else if (child?.type === CardFooter) footer = child;
else body = child;
});
return (
<div className="card">
{header && <div className="card-header">{header}</div>}
<div className="card-body">{body}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
function CardHeader({ children }) {
return children;
}
function CardFooter({ children }) {
return children;
}
Card.Header = CardHeader;
Card.Footer = CardFooter;
// Usage
<Card>
<Card.Header>
<h2>Title</h2>
</Card.Header>
<p>Card content goes here</p>
<Card.Footer>
<button>Action</button>
</Card.Footer>
</Card>;Polymorphic Components
Components that can render as different elements.
JAVASCRIPT
function Box({ as: Component = 'div', children, ...props }) {
return <Component {...props}>{children}</Component>;
}
// Usage
<Box>Default div</Box>
<Box as="section">Renders as section</Box>
<Box as="article">Renders as article</Box>
<Box as={Link} to="/about">Renders as Link component</Box>With TypeScript
TYPESCRIPT
type BoxProps<T extends React.ElementType> = {
as?: T;
children?: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children'>;
function Box<T extends React.ElementType = 'div'>({
as,
children,
...props
}: BoxProps<T>) {
const Component = as || 'div';
return <Component {...props}>{children}</Component>;
}
// Now TypeScript knows the correct props
<Box as="a" href="/link">Link</Box> // ✓ href is valid
<Box as="button" onClick={handler}>Click</Box> // ✓ onClick is valid
<Box as="a" onClick={handler}>Error</Box> // ✗ would warnContainer/Presentational Pattern
Separate logic from presentation.
JAVASCRIPT
// Container: handles logic and data
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
useEffect(() => {
fetchUsers()
.then(setUsers)
.finally(() => setLoading(false));
}, []);
const filteredUsers = useMemo(
() =>
users.filter((u) => u.name.toLowerCase().includes(filter.toLowerCase())),
[users, filter],
);
const handleDelete = async (id) => {
await deleteUser(id);
setUsers(users.filter((u) => u.id !== id));
};
return (
<UserList
users={filteredUsers}
loading={loading}
filter={filter}
onFilterChange={setFilter}
onDelete={handleDelete}
/>
);
}
// Presentational: purely renders UI
function UserList({ users, loading, filter, onFilterChange, onDelete }) {
if (loading) return <Skeleton />;
return (
<div>
<input
value={filter}
onChange={(e) => onFilterChange(e.target.value)}
placeholder="Filter users..."
/>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name}
<button onClick={() => onDelete(user.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}Summary
| Pattern | Use Case | Benefits |
|---|---|---|
| Compound Components | Complex UI with shared state | Flexible, declarative API |
| Render Props | Sharing behavior | Explicit data flow |
| HOCs | Cross-cutting concerns | Reusable enhancement |
| Custom Hooks | Reusable logic | Clean, composable |
| Controlled/Uncontrolled | Form inputs | Flexibility |
| Provider | Dependency injection | Testable, decoupled |
| Slot | Customizable layouts | Clear structure |
| Polymorphic | Flexible rendering | Type-safe flexibility |
| Container/Presentational | Separation of concerns | Testable, reusable |
Key Principles:
- Prefer composition over inheritance
- Make components do one thing well
- Design APIs from usage, not implementation
- Start simple, add patterns when needed
- Custom hooks are often the simplest solution