The render props pattern is a technique for sharing stateful logic between components by passing a function as a prop. The host component manages state and calls that function with the state as arguments, letting the consumer decide what to render.
The problem it solves is logic reuse without inheritance. Before hooks, if two components needed the same piece of state (mouse position, window size, data fetching status) you had three options: copy-paste the logic, lift state up (coupling unrelated components), or use render props / HOCs. Render props kept the logic encapsulated in one place while giving the consumer full rendering control.
// DataFetcher owns fetching logic; caller decides the UI
function DataFetcher({ url, render }) {
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch(url)
.then(r => r.json())
.then(d => { setData(d); setLoading(false); });
}, [url]);
// Delegates rendering entirely to the caller
return render({ data, loading });
}
// Usage — caller controls the UI
<DataFetcher
url="/api/users"
render={({ data, loading }) =>
loading ? <Spinner /> : <UserList users={data} />
}
/>
Rule of thumb: Render props shine when you want to share complex stateful behaviour while letting each consumer render something different.
Function-as-child (also called "children as a function") is just a render prop
where the prop name is children instead of a custom name like render. The component
calls props.children(...) exactly as it would call props.render(...).
The only differences are:
- Syntactic: the function lives inside the JSX tag body, which reads more naturally for simple cases.
- Discoverability: explicit render props (
render,renderHeader, etc.) are visible in prop-types/TypeScript signatures;childrencan surprise readers if they expect a ReactNode.
// Explicit render prop
<Mouse render={({ x, y }) => <Cursor x={x} y={y} />} />
// Function-as-child — identical behaviour, different syntax
<Mouse>
{({ x, y }) => <Cursor x={x} y={y} />}
</Mouse>
// Inside Mouse — both approaches call the same way
function Mouse({ children }) {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
return (
<div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}>
{children(pos)} {/* or props.render(pos) */}
</div>
);
}
Rule of thumb: Use an explicit render prop when you need multiple render slots
(renderHeader, renderFooter); use children when there is only one slot and
the JSX nesting reads naturally.
A Higher-Order Component is a function that takes a component and returns a new,
enhanced component. It is the React adaptation of the higher-order function concept —
just as Array.map takes a function and returns a new array, an HOC takes a component
and returns a component.
The canonical signature is:
// Generic signature: WrappedComponent in, EnhancedComponent out
function withSomething<P extends object>(
WrappedComponent: React.ComponentType<P>
): React.ComponentType<P & InjectedProps> {
function WithSomething(props: P) {
const injected = useSomethingLogic(); // HOC-owned logic
return <WrappedComponent {...props} {...injected} />;
}
// Naming convention: prefix with "with"
WithSomething.displayName =
`WithSomething(${WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'})`;
return WithSomething;
}
Key points:
- The naming convention is
withXxx(camelCase, "with" prefix) — e.g.,withAuth,withTheme,withLogger. - Set
displayNameso React DevTools showsWithAuth(Dashboard)rather than an anonymous component. - The HOC does not mutate the wrapped component; it wraps it.
Rule of thumb: If a function takes a component and returns a component, it is an
HOC; name it withXxx and set displayName.
You can nest HOC calls directly, but deep nesting reads inside-out and is hard to
maintain. The standard solution is a compose utility (available in lodash, Redux,
Ramda, or trivial to write) that applies functions right-to-left so the reading order
matches the wrapping order.
// Naive nesting — reads inside-out, breaks easily when reordering
export default withLogger(withAuth(withTheme(MyComponent)));
// compose applies right-to-left: theme → auth → logger (outermost last)
import { compose } from 'redux'; // or lodash/fp
const enhance = compose(
withLogger, // applied last (outermost)
withAuth, // applied second
withTheme, // applied first (innermost, closest to component)
);
export default enhance(MyComponent);
// Minimal compose implementation for reference
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
Order matters: the outermost HOC's props arrive first. If withAuth reads a theme
prop injected by withTheme, then withTheme must be applied before withAuth
(listed after it in compose).
Rule of thumb: Use compose when stacking three or more HOCs; keep the list in
inside-out (most-specific-first) order so execution matches your mental model.
Classic cross-cutting concerns suited to HOCs:
- Authentication / authorisation guards — redirect unauthenticated users before rendering a page component.
- Analytics / logging — record component mount, unmount, and prop changes without touching business logic components.
- Error boundaries — wrap any component with standardised error UI.
withAuth example in detail:
// withAuth.jsx — auth guard HOC
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
function withAuth(WrappedComponent, requiredRole = null) {
function WithAuth(props) {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
if (requiredRole && user.role !== requiredRole)
return <Navigate to="/forbidden" replace />;
// User is authenticated (and has the right role) — render component
return <WrappedComponent {...props} />;
}
WithAuth.displayName = `WithAuth(${WrappedComponent.displayName ?? WrappedComponent.name})`;
return WithAuth;
}
// Usage
export default withAuth(AdminDashboard, 'admin');
Rule of thumb: HOCs are ideal for cross-cutting concerns that need to intercept rendering (guards, error boundaries) or observe lifecycle events (logging) without altering the wrapped component's own logic.
Prop collision occurs when an HOC injects a prop whose name clashes with a prop the wrapped component (or a parent) already uses. The HOC's value silently wins because it is spread last (or first), overwriting the intended value and causing subtle bugs.
Prevention strategies:
// BAD — HOC spreads injected props after ownProps; "user" collision
function withUser(WrappedComponent) {
return function(props) {
const user = useCurrentUser();
// If parent passes user={guestUser}, HOC's user overwrites it silently
return <WrappedComponent {...props} user={user} />;
};
}
// GOOD — namespace injected props under a dedicated key
function withUser(WrappedComponent) {
return function({ injectedUser, ...ownProps }) {
const user = useCurrentUser();
// Injected prop has a unique name; no risk of shadowing parent props
return <WrappedComponent {...ownProps} currentUser={user} />;
};
}
// BEST — use TypeScript to make the contract explicit
type WithUserProps = { currentUser: User };
function withUser<P extends WithUserProps>(
WrappedComponent: React.ComponentType<P>
) { /* ... */ }
Other strategies: document all injected prop names, prefix them (_injectedUser),
or prefer hooks (which do not pollute the prop namespace at all).
Rule of thumb: Never spread HOC-injected props with generic names; use descriptive, namespaced names and make them explicit in TypeScript types.
Custom hooks solve the same logic-reuse problem without the downsides of both patterns:
| Issue | Render props / HOCs | Custom hooks |
|---|---|---|
| Extra DOM nodes | Yes — every HOC / render-prop host adds a wrapper in the tree | No — hooks run inside the calling component |
| Prop drilling / collision | HOCs inject props that can clash | Hooks return values via destructuring — no prop namespace |
| Composability | HOCs compose left-to-right but reading order is confusing | Just call multiple hooks sequentially |
| TypeScript | Generics + conditional types needed for good inference | Plain function return types — straightforward |
| DevTools clarity | Deep "wrapper hell" in component tree | Flat component tree |
// Pre-hooks: render prop for mouse position
<MouseTracker render={({ x, y }) => <Crosshair x={x} y={y} />} />
// Post-hooks: extract into a custom hook — same logic, no wrapper
function useMousePosition() {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
React.useEffect(() => {
const handler = e => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return pos;
}
function Crosshair() {
const { x, y } = useMousePosition(); // clean, no wrapper
return <div style={{ top: y, left: x }} />;
}
Rule of thumb: Reach for a custom hook first; only use render props or HOCs when you need to control rendering structure (render prop) or integrate with class components or third-party code that cannot use hooks (HOC).
Render props remain the best choice in several scenarios:
- Controlled render delegation — when the parent needs to decide what renders inside a complex host (e.g., a virtualised list that injects row index/style and expects the caller to return the row JSX).
- Class component consumers — hooks cannot run inside class components; an HOC or render-prop component is the only way to feed hook-based logic into them.
- Library APIs —
react-router(<Route render>in v5),formik(<Field>), andreact-window(<FixedSizeList>) expose render props because they cannot know your render tree ahead of time.
// react-window — render prop is the correct API; hooks cannot replace it here
import { FixedSizeList } from 'react-window';
function VirtualList({ items }) {
const Row = ({ index, style }) => (
// style MUST be applied for virtualisation to work
<div style={style}>{items[index].name}</div>
);
return (
<FixedSizeList height={400} itemCount={items.length} itemSize={35} width="100%">
{Row}
</FixedSizeList>
);
}
Rule of thumb: Render props are still the right tool when a library needs you to supply a JSX factory, or when the component consuming your logic is a class component.
A wrapped component's static methods (e.g., getLayout, fetchData,
propTypes) are defined on the original component object. The HOC returns a new
function object that does not automatically copy those statics, so callers that
look up EnhancedComponent.getLayout find undefined.
The fix is hoist-non-react-statics, a small utility that copies all non-React
statics from the wrapped component to the wrapper:
import hoistNonReactStatics from 'hoist-non-react-statics';
function withLogger(WrappedComponent) {
function WithLogger(props) {
React.useEffect(() => { console.log('mounted'); }, []);
return <WrappedComponent {...props} />;
}
WithLogger.displayName =
`WithLogger(${WrappedComponent.displayName ?? WrappedComponent.name})`;
// Copy getLayout, fetchData, propTypes, etc. from original
hoistNonReactStatics(WithLogger, WrappedComponent);
return WithLogger;
}
// Statics survive the wrapping
WithLogger.getLayout === WrappedComponent.getLayout; // true
React-specific statics (defaultProps, propTypes, contextType, displayName,
getDerivedStateFromProps, etc.) are intentionally not hoisted — the library
has a hardcoded exclusion list for them.
Rule of thumb: Always call hoistNonReactStatics(Wrapper, WrappedComponent)
inside every HOC factory so page-level statics (especially Next.js getLayout) are
not silently dropped.
HOCs break ref forwarding by default because ref is not a real prop — it is
handled by React's reconciler and does not appear in props. A ref attached to
the HOC wrapper points at the wrapper, not the underlying DOM node or component.
The fix is React.forwardRef:
function withFocusRing(WrappedComponent) {
// forwardRef creates a component that passes its ref down
const WithFocusRing = React.forwardRef(function(props, ref) {
const [focused, setFocused] = React.useState(false);
return (
<div className={focused ? 'ring' : ''}>
<WrappedComponent
{...props}
ref={ref} // forward the ref to the real component
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
</div>
);
});
WithFocusRing.displayName =
`WithFocusRing(${WrappedComponent.displayName ?? WrappedComponent.name})`;
hoistNonReactStatics(WithFocusRing, WrappedComponent);
return WithFocusRing;
}
// Caller gets a ref to the real input, not the wrapper div
const EnhancedInput = withFocusRing(React.forwardRef((props, ref) =>
<input ref={ref} {...props} />
));
const ref = React.createRef();
<EnhancedInput ref={ref} />;
Rule of thumb: Wrap the inner component in React.forwardRef and thread the
ref argument through to WrappedComponent; always pair this with hoistNonReactStatics.
Both patterns can introduce unnecessary re-renders if not handled carefully.
HOC issues:
- Each HOC in a composition chain is a separate React component —
React.memoon the inner component does not prevent the wrapper from re-rendering. - If the HOC creates a new object or function on every render and passes it as a prop, the wrapped component will see a new reference and re-render even if the underlying data did not change.
Render prop issues (the classic "always re-renders" problem):
// BAD — inline arrow function creates a new function reference every render
// → Mouse re-renders, calls render(), child always re-renders
<Mouse render={({ x, y }) => <Crosshair x={x} y={y} />} />
// GOOD — stable function reference with useCallback (or define outside render)
function App() {
const renderCrosshair = React.useCallback(
({ x, y }) => <Crosshair x={x} y={y} />,
[] // stable — no deps
);
return <Mouse render={renderCrosshair} />;
}
For HOCs, apply React.memo to each layer independently, or memoize injected
values inside the HOC with useMemo/useCallback.
Rule of thumb: Stabilise function references passed as render props with
useCallback; inside HOCs, memoize injected values to avoid cascading re-renders.
The key is using generics to capture the wrapped component's prop type and subtract the injected props so callers do not have to re-supply them.
// Props injected by the HOC
interface WithAuthProps {
currentUser: User;
}
// P = full props of wrapped component
// We subtract WithAuthProps so callers don't pass currentUser manually
function withAuth<P extends WithAuthProps>(
WrappedComponent: React.ComponentType<P>
): React.ComponentType<Omit<P, keyof WithAuthProps>> {
function WithAuth(props: Omit<P, keyof WithAuthProps>) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
// Cast needed because TS can't fully verify the Omit + spread
return <WrappedComponent {...(props as P)} currentUser={user} />;
}
WithAuth.displayName =
`WithAuth(${WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'})`;
hoistNonReactStatics(WithAuth, WrappedComponent);
return WithAuth;
}
// Usage — TypeScript knows currentUser is supplied by HOC, not by caller
interface DashboardProps extends WithAuthProps { title: string; }
function Dashboard({ currentUser, title }: DashboardProps) { /* ... */ }
const ProtectedDashboard = withAuth(Dashboard);
<ProtectedDashboard title="Home" /> // ✓ — no currentUser required
Rule of thumb: Use P extends InjectedProps on the generic and Omit<P, keyof InjectedProps>
on the output type so the HOC's additions are invisible to callers.
Strategy 1 — Test the wrapped component in isolation. Export the unwrapped component as a named export alongside the default HOC-wrapped export. In tests, import the unwrapped version and pass the injected props manually. This keeps unit tests free of HOC side-effects.
Strategy 2 — Mock the HOC's dependencies and test the full composed component.
Useful for integration tests that need to verify the HOC's interception logic (e.g.,
that withAuth redirects unauthenticated users).
// userList.tsx — dual export pattern
export function UserList({ currentUser }: WithAuthProps & { currentUser: User }) {
return <div>{currentUser.name}</div>;
}
export default withAuth(UserList); // default = enhanced
// userList.test.tsx
import { UserList } from './userList'; // named — no HOC
import { render, screen } from '@testing-library/react';
test('renders user name', () => {
render(<UserList currentUser={{ name: 'Alice', role: 'user' }} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
// Integration test — mock auth context
import WrappedUserList from './userList'; // default — HOC included
test('redirects when unauthenticated', () => {
mockUseAuth({ user: null, loading: false });
render(<WrappedUserList />, { wrapper: MemoryRouterWrapper });
expect(mockNavigate).toHaveBeenCalledWith('/login', expect.anything());
});
Rule of thumb: Always export the base component as a named export so unit tests can bypass the HOC; use integration tests only when you need to test the HOC's own behaviour.
The decision hinges on who controls the rendered output and how the logic is consumed.
| Choose render prop when… | Choose HOC when… |
|---|---|
| The consumer needs to render different UI per use-site | Every consumer renders roughly the same structure |
| You want explicit, visible data flow in JSX | You want transparent wrapping (consumer doesn't change) |
| You need to compose render slots (header, body, footer) | You need to decorate a whole page/route component |
| The consuming component is a function component | You need to wrap class components with consistent behaviour |
// Render prop — consumer decides the UI
<Toggle>
{({ on, toggle }) =>
on
? <Button onClick={toggle}>Turn Off</Button>
: <Button onClick={toggle}>Turn On</Button>
}
</Toggle>
// HOC — consumer is unaware of the wrapping
const AnalyticsButton = withPageViewLogger(Button);
// Button code is unchanged; analytics are injected invisibly
<AnalyticsButton onClick={handleClick}>Buy Now</AnalyticsButton>
Rule of thumb: Use render props when the consumer owns the output markup; use HOCs when you want to transparently add behaviour to an existing component.
Wrapper hell (also called "HOC hell" or "pyramid of doom") describes a component tree where many HOC layers surround a single component, making DevTools output nearly unreadable and stack traces hard to follow.
In React DevTools you see something like:
// Component tree with 5 HOCs — hard to navigate
<WithRouter>
<WithTheme>
<WithAuth>
<WithLogger>
<WithErrorBoundary>
<Dashboard />
</WithErrorBoundary>
</WithLogger>
</WithAuth>
</WithTheme>
</WithRouter>
Solutions:
- Replace with hooks — collapse all five wrappers into hook calls inside
Dashboard. - Use
compose— at least the source code reads linearly even if the tree is deep. - Set meaningful
displayNameon each HOC so DevTools showsWithAuth(Dashboard).
// After refactoring to hooks — flat tree, same logic
function Dashboard() {
const { params } = useRouter();
const theme = useTheme();
const { user } = useAuth();
usePageLogger();
useErrorBoundary();
// ...
}
Rule of thumb: If DevTools shows more than two or three wrapper components around a single leaf, consider collapsing the HOC stack into custom hooks.
No — hooks cannot run inside class components. An HOC (or render prop) remains the only way to inject hook-based logic into a class component. This matters for legacy codebases or third-party libraries still using class components.
The recommended migration path is incremental:
// Step 1 — write the logic as a hook
function useTheme() {
return React.useContext(ThemeContext);
}
// Step 2 — create a thin HOC that calls the hook and injects the result
// This bridges class components without rewriting them
function withTheme(WrappedComponent) {
function WithTheme(props) {
const theme = useTheme(); // hook runs here (function component)
return <WrappedComponent {...props} theme={theme} />;
}
WithTheme.displayName = `WithTheme(${WrappedComponent.name})`;
return WithTheme;
}
// Step 3 — class component uses the HOC today
class Toolbar extends React.Component {
render() {
return <div style={{ background: this.props.theme.bg }}>…</div>;
}
}
export default withTheme(Toolbar);
// Step 4 — when Toolbar is converted to a function component, drop the HOC
// and call useTheme() directly
Rule of thumb: Write all new logic as hooks; wrap in a thin HOC only to bridge class components; remove the HOC shim when the class component is converted.
React uses displayName (or the function name as a fallback) when rendering component
trees in DevTools and in error stack traces. Without it, every HOC-wrapped
component shows as Anonymous or just the HOC function name, making debugging very
hard when multiple HOCs are stacked.
The standard format is WithXxx(OriginalName):
function withSubscription(WrappedComponent) {
function WithSubscription(props) {
// ...
return <WrappedComponent {...props} />;
}
// Correct format: WithHocName(WrappedName)
WithSubscription.displayName =
`WithSubscription(${
WrappedComponent.displayName // already set display name
?? WrappedComponent.name // function/class name
?? 'Component' // last resort fallback
})`;
return WithSubscription;
}
// DevTools now shows: WithSubscription(Dashboard)
// instead of: WithSubscription or Anonymous
Some teams use a getDisplayName helper to deduplicate the ternary chain across
all HOCs in a codebase.
Rule of thumb: Always set displayName using the WithXxx(InnerName) format;
check all three sources (displayName, name, fallback) in that priority order.
Yes. An error boundary must be a class component (React's componentDidCatch
API is not yet available as a hook), but you can expose its caught error via a
render prop so the caller controls the fallback UI — combining the two patterns.
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
reportError(error, info); // side-effect: logging service
}
render() {
const { error } = this.state;
const { children, fallback } = this.props;
if (error) {
// Delegate fallback UI to the caller via render prop
return typeof fallback === 'function'
? fallback(error) // render prop form
: fallback ?? <p>Something went wrong.</p>;
}
return children;
}
}
// Caller controls the fallback UI
<ErrorBoundary fallback={err => <ErrorPage message={err.message} />}>
<UserProfile userId={id} />
</ErrorBoundary>
This pattern is exactly how the popular react-error-boundary library works
internally (renderError / FallbackComponent prop).
Rule of thumb: Error boundary logic stays in a class component; expose the fallback slot as a render prop so each call site can render appropriate recovery UI.
React.PureComponent and React.memo perform a shallow comparison of props.
An inline arrow function creates a new function object on every parent render, so
the render-prop component always sees a changed render prop — the shallow compare
fails, PureComponent's optimisation is bypassed, and the component re-renders
unconditionally.
class Mouse extends React.PureComponent {
// shallow compare on props.render — always a new reference → always re-renders
render() { return this.props.render(this.state); }
}
// BAD — new arrow function on every render of App
class App extends React.Component {
render() {
return <Mouse render={pos => <Cat {...pos} />} />; // new fn each time!
}
}
// GOOD — define the render function as an instance method; reference is stable
class App extends React.Component {
renderCat = pos => <Cat {...pos} />; // stable class field
render() {
return <Mouse render={this.renderCat} />;
}
}
// GOOD (function component equivalent) — useCallback for stable reference
function App() {
const renderCat = React.useCallback(pos => <Cat {...pos} />, []);
return <Mouse render={renderCat} />;
}
Rule of thumb: Never pass an inline arrow function as a render prop to a
PureComponent or memo-wrapped component; stabilise the reference with
useCallback or a class instance method.
More Patterns interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.