Higher-order Components
We have already separated reusable behavior from output in two ways. A custom Hook lets the component that renders the view call for behavior directly. A render prop lets a behavior-owning component ask its caller for output.
There is one older composition pattern that starts at a different boundary: a caller may already have a component type and want to add behavior around every instance of it.
Imagine two views that both need a Boolean value and an operation to change it:
function PowerButton() {
const [on, setOn] = useState(false);
return (
<button
aria-pressed={on}
onClick={() => setOn((current) => !current)}
>
Power is {on ? 'on' : 'off'}
</button>
);
}
function Details() {
const [on, setOn] = useState(true);
return (
<section>
<h2>
<button
aria-expanded={on}
onClick={() => setOn((current) => !current)}
>
Shipping details
</button>
</h2>
{on && <p>Ships in two business days.</p>}
</section>
);
}
The repeated part is the stateful interface supplied to each view; the host trees and accessible meanings differ.
That leaves the next hole:
if only the target component type varies, how can we create a new component type that supplies the same behavior around it?
Repeating the wrapper #
Start by spelling out a wrapper for one target. PowerButtonWithToggle owns state, then renders PowerButton with the props it needs:
const { useState } = React;
function PowerButton({ on, toggle }) {
return (
<button aria-pressed={on} onClick={toggle}>
Power is {on ? 'on' : 'off'}
</button>
);
}
function PowerButtonWithToggle() {
const [on, setOn] = useState(false);
function toggle() {
setOn((current) => !current);
}
return <PowerButton on={on} toggle={toggle} />;
}
root.render(<PowerButtonWithToggle />);
Waiting to run
Not run yet.
This is an ordinary component relationship. The wrapper returns an element whose type is PowerButton; React calls the wrapper, then uses its result to render the button component. The wrapper introduces a component boundary, but it has not added a DOM node of its own.
Making the disclosure needs almost the same wrapper:
function DetailsWithToggle() {
const [on, setOn] = useState(true);
function toggle() {
setOn((current) => !current);
}
return <Details on={on} toggle={toggle} />;
}
Only the target type and the initial value changed. Copying a wrapper per target leaves the behavior protocol in several places. The failed repetition narrows the hole:
the reusable operation must receive a component type and return a component type
A function that produces a component #
A JavaScript function can take one component type and return another. Put the state in the returned component, not in that outer factory:
const { useState } = React;
function withToggle(WrappedComponent) {
function WithToggle({ initialOn = false, ...props }) {
const [on, setOn] = useState(initialOn);
function toggle() {
setOn((current) => !current);
}
return (
<WrappedComponent
{...props}
on={on}
toggle={toggle}
/>
);
}
return WithToggle;
}
function PowerButton({ on, toggle }) {
return (
<button aria-pressed={on} onClick={toggle}>
Power is {on ? 'on' : 'off'}
</button>
);
}
const TogglePowerButton = withToggle(PowerButton);
root.render(<TogglePowerButton />);
Waiting to run
Not run yet.
withToggle(PowerButton) runs as ordinary JavaScript and produces WithToggle, a new component function. Later, rendering <TogglePowerButton /> asks React to render that returned component. It calls useState at the top level of WithToggle, where Hooks are allowed.[5]
The two times matter:
module setup rendering an instance
withToggle(PowerButton) <TogglePowerButton initialOn={true} />
| |
v v
returns the WithToggle component type WithToggle calls useState
The factory itself is not rendering an instance. It has no Hook state to attach to, so this is wrong:
function withToggle(WrappedComponent) {
const [on, setOn] = useState(false); // wrong: Hook in a factory
return function WithToggle() {
return <WrappedComponent on={on} />;
};
}
Hooks belong to components or custom Hooks. The returned WithToggle is the component that owns each stateful instance.
This shape is a higher-order component (HOC): a function that accepts a component and returns a new component.[1] The linked guide is legacy documentation; HOCs remain useful to recognize and sometimes maintain, but they are not the modern default for new behavior reuse.
Applying one behavior to different views #
The HOC can wrap target components with unrelated output, provided they agree on the injected props. Here one target is a pressed button and the other is a disclosure. Both remain accessible in the way appropriate to their own roles:
const { useState } = React;
function withToggle(WrappedComponent) {
function WithToggle({ initialOn = false, ...props }) {
const [on, setOn] = useState(initialOn);
function toggle() {
setOn((current) => !current);
}
return <WrappedComponent {...props} on={on} toggle={toggle} />;
}
return WithToggle;
}
function PowerButton({ on, toggle, label }) {
return (
<button aria-pressed={on} onClick={toggle}>
{label}: {on ? 'on' : 'off'}
</button>
);
}
function ShippingDetails({ on, toggle, children }) {
return (
<section>
<h2>
<button aria-expanded={on} onClick={toggle}>
Shipping details
</button>
</h2>
{on && <p>{children}</p>}
</section>
);
}
const TogglePowerButton = withToggle(PowerButton);
const ToggleShippingDetails = withToggle(ShippingDetails);
function App() {
return (
<section>
<TogglePowerButton label="Power" />
<ToggleShippingDetails initialOn={true}>
Ships in two business days.
</ToggleShippingDetails>
</section>
);
}
root.render(<App />);
Waiting to run
Not run yet.
label and children are unrelated caller props. WithToggle passes them through with {...props}; PowerButton and ShippingDetails decide how to use them. initialOn is consumed by the wrapper because it configures wrapper-owned state. on and toggle are created by the wrapper and injected into the target.[2]
That creates a small prop-ownership contract:
| Prop | Owner | Destination |
|---|---|---|
initialOn |
WithToggle |
initializes its state; not forwarded |
label, children, and other unrelated props |
caller | forwarded to the target |
on, toggle |
WithToggle |
injected into the target |
The order in the JSX is deliberate:
<WrappedComponent {...props} on={on} toggle={toggle} />
The injected values come after the spread, so a caller-provided on or toggle cannot replace the behavior contract accidentally. A HOC must document names it reserves. Choosing a more domain-specific injected name, or changing the interface to avoid overlap, is often clearer than silently competing for a common prop name.
key is different: React uses it to identify an element among siblings and does not deliver it as an ordinary prop to WithToggle or WrappedComponent. Passing {...props} does not turn a key into a target prop.[2]
Instances still own separate state #
The HOC application produces one component type. Rendering that type twice produces two component instances at two positions, so it produces two independent state values:
const { useState } = React;
function withToggle(WrappedComponent) {
function WithToggle({ initialOn = false, ...props }) {
const [on, setOn] = useState(initialOn);
function toggle() {
setOn((current) => !current);
}
return <WrappedComponent {...props} on={on} toggle={toggle} />;
}
return WithToggle;
}
function StatusButton({ on, toggle, name }) {
return (
<button aria-pressed={on} onClick={toggle}>
{name}: {on ? 'enabled' : 'disabled'}
</button>
);
}
const ToggleStatusButton = withToggle(StatusButton);
function App() {
return (
<section>
<ToggleStatusButton name="Email" />
<ToggleStatusButton name="SMS" initialOn={true} />
</section>
);
}
root.render(<App />);
Waiting to run
Not run yet.
Toggle Email. Its state changes without changing SMS. The factory call is reused, but the useState call runs separately for each rendered ToggleStatusButton position. Reusing a component type, like reusing a custom Hook, does not share local state between instances.[3]
The HOC also does not call the target as an ordinary function:
return WrappedComponent({ ...props, on, toggle }); // wrong
It returns an element instead:
return <WrappedComponent {...props} on={on} toggle={toggle} />;
An element gives React a component boundary to render and reconcile. Directly invoking a component can break Hook ordering and bypasses the normal element relationship.
Keep the new type stable #
withToggle creates a fresh component function each time it runs. That is why applying it during another component's render is a bug:
function Settings() {
const TogglePowerButton = withToggle(PowerButton); // wrong here
return <TogglePowerButton />;
}
Every render of Settings makes a different TogglePowerButton type. At that position React sees a type change, removes the earlier wrapper subtree, and mounts a new one. State below it resets rather than being preserved.[3]
Apply the HOC once at module scope instead:
const TogglePowerButton = withToggle(PowerButton);
function Settings() {
return <TogglePowerButton />;
}
The module-scope constant preserves the wrapper type across renders; state updates and target memoization remain separate concerns.
Do not change the input component #
An HOC creates a wrapper around the input type. It should not modify that input type:
function withLog(WrappedComponent) {
WrappedComponent.prototype.componentDidUpdate = function () { // wrong
console.log('updated');
};
return WrappedComponent;
}
Mutation makes the result depend on application order, cannot work for function components in this form, and affects every other use of the input component. Returning a wrapper leaves the original component available unchanged:
function withLog(WrappedComponent) {
function WithLog(props) {
console.log('rendering', WrappedComponent.displayName ?? WrappedComponent.name);
return <WrappedComponent {...props} />;
}
return WithLog;
}
The log above is only a teaching sketch: render calculations must be pure, so production diagnostics that cause external effects belong outside render.[4] The important part is structural: the input is represented by <WrappedComponent />, not altered.
For development tools, a wrapper can provide a readable optional displayName:
function withToggle(WrappedComponent) {
function WithToggle(props) {
// ...
}
const name = WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component';
WithToggle.displayName = `withToggle(${name})`;
return WithToggle;
}
This only labels the new type. It changes neither its behavior nor its state identity.
Composing wrappers #
Because an HOC accepts a component type and returns one, the output of one can be the input to another:
const EnhancedProfile = withTracking(withToggle(Profile));
Read from inside out: withToggle(Profile) makes one type, then withTracking wraps that type. Each wrapper needs a clear prop contract and should forward props it does not own. Several wrappers can make ownership difficult to trace, especially when they reserve similar prop names or add stateful boundaries.
This is the reason HOCs were valuable in earlier React ecosystems: they package behavior at the component-type boundary. It is also why they can become opaque. A caller looking at <EnhancedProfile /> cannot see the intermediate layers from its JSX alone.
HOC, custom Hook, or render prop? #
All three patterns can share behavior while allowing different markup. They put the ownership boundary in different places:
| Need | Usually prefer |
|---|---|
| a function component wants values and operations for its own output | custom Hook |
| a behavior-owning component controls a caller-defined output slot | render prop |
| an existing component type must be wrapped with a cross-cutting interface | HOC |
A custom Hook is usually the most direct modern choice for the toggle:
function PowerButton() {
const { on, toggle } = useToggle();
return <button aria-pressed={on} onClick={toggle}>Power</button>;
}
It adds no wrapper component type and makes the stateful dependency visible where the output is written.[6] A render prop is useful when the behavior owner needs to control an output slot. An HOC remains useful when adapting an existing component interface, or when working with an API that already exposes this component-wrapping form.
None is an automatic upgrade over the others. Start with the ownership requirement: who should call for the behavior, who should choose the output, and is there already a component type that must be enhanced?
Filling the hole #
The duplicated wrappers differed only in the target type. A factory could accept that type once and return one named wrapper type that owns the reusable state for every rendered instance:
function withToggle(WrappedComponent) {
function WithToggle({ initialOn = false, ...props }) {
const [on, setOn] = useState(initialOn);
function toggle() {
setOn((current) => !current);
}
return <WrappedComponent {...props} on={on} toggle={toggle} />;
}
return WithToggle;
}
withTogglereceives a component type and returns a new component type.- Each rendered
WithToggleinstance owns its own Hook state. - The wrapper consumes configuration, forwards unrelated props, and injects the behavior it owns.
- The target is rendered as an element, so ordinary React rendering and reconciliation still apply.
Final definition #
A higher-order component is a function that takes a component type and returns a new component type. The returned wrapper can render the input component with transformed, forwarded, or injected props.
HOCs are a component-type composition pattern. They do not share local state between separate rendered instances, and creating a new wrapper type during render changes identity and can reset state.
Summary #
Higher-order components fill the component-type-wrapping hole:
- duplicated wrappers reveal behavior that varies only by the target component type
- an HOC takes that type and returns a named wrapper component type
- apply an HOC at module scope; render its returned type later as JSX
- Hooks belong in the returned component, not in the HOC factory
- each rendered wrapper instance has its own local state
- render
<WrappedComponent />; do not directly call a component function - a wrapper can add a React component boundary without adding a DOM node
- consume wrapper configuration, forward unrelated props, and make injected-prop collisions explicit
keyremains React's special identity input rather than an ordinary forwarded prop- do not mutate the input component; return a wrapper around it
- recreating an HOC during render makes a fresh type and can remount its subtree
- custom Hooks are usually the simpler modern default; render props and HOCs remain useful when their ownership boundaries fit the problem
Notes
- The live demos in this article use the React 19.2.5 and react-dom 19.2.5 development modules. Higher-order components are a composition pattern, not a separate React API.
References
- React legacy documentation: Higher-Order Components (opens in a new tab) · Back
- React: Passing Props to a Component (opens in a new tab) · Back
- React: Preserving and Resetting State (opens in a new tab)
- React: Components and Hooks must be pure (opens in a new tab) · Back
- React: Rules of Hooks (opens in a new tab) · Back
- React: Reusing Logic with Custom Hooks (opens in a new tab) · Back