The HOC + Render Props Patterns I Still Read in Legacy Repos
Hooks made HOCs and render props optional, but pre-2019 codebases still ship them. Four patterns to recognize when you inherit a Redux-era React app.
By @diyahassan
March 7, 2026
·
Updated May 20, 2026
808 views
20
4.2 (11)
// withCounter is the textbook HOC: it accepts a component and returns a new
// component that injects extra props (count and increment). The wrapped
// component cannot tell if its count came from local state or from a parent.
// We need a class harness because the wrapped component is a class; in real
// React this is just `extends React.Component`.
class Component {
constructor(props) { this.props = props || {}; this.state = {}; }
setState(patch) { this.state = Object.assign({}, this.state, typeof patch === 'function' ? patch(this.state) : patch); this._render && this._render(); }
}
// The HOC. Wraps any component and gives it { count, increment } props.
function withCounter(Wrapped, initial) {
return class WithCounter extends Component {
constructor(props) {
super(props);
this.state = { count: initial || 0 };
this.increment = () => this.setState((s) => ({ count: s.count + 1 }));
}
render() {
const enhanced = Object.assign({}, this.props, {
count: this.state.count,
increment: this.increment,
});
const inner = new Wrapped(enhanced);
inner.props = enhanced;
return inner.render();
}
};
}
// A consumer that knows nothing about counter state.
class ClickCounter extends Component {
render() {
const { count, increment, label } = this.props;
return { type: 'button', props: { onClick: increment }, children: [label + ': ' + count] };
}
}
const Wrapped = withCounter(ClickCounter, 5);
const instance = new Wrapped({ label: 'clicks' });
const tree1 = instance.render();
console.log('initial render:', tree1.children[0]);
console.log('button props -> count injected, increment is a function:',
'count' in tree1.props ? 'no (in children)' : 'count in injected props',
typeof instance.increment);
instance.increment();
const tree2 = instance.render();
console.log('after increment:', tree2.children[0]);
instance.increment();
instance.increment();
const tree3 = instance.render();
console.log('after two more :', tree3.children[0]);The contract is what makes HOCs unfamiliar to anyone who started on hooks. The HOC owns a slice of state, the wrapped component receives that state as props, and the wrapped component never knows the state lives somewhere else. The two practical wins were that the consumer stayed a pure render function and that you could compose multiple HOCs onto the same component, which is how compose(withRouter, connect(mapState, mapDispatch))(MyView) came to be the canonical Redux + react-router-v4 wrapping. The cost is that the wrapper hierarchy shows up in React DevTools as WithCounter(ClickCounter), debugging stack traces get noisy, and prop collisions (HOC overwriting a prop the consumer already had) are silent unless you go out of your way to detect them. Hooks did away with both costs.
// The render-props alternative inverts the call shape: instead of wrapping
// the consumer, you ship a stateful component whose only job is to expose its
// state via a `render` prop (or function-as-children). Same data flow,
// different ergonomics. Tracker calls in the React docs c. 2017.
class Component {
constructor(props) { this.props = props || {}; this.state = {}; }
setState(patch) { this.state = Object.assign({}, this.state, typeof patch === 'function' ? patch(this.state) : patch); }
}
// The provider component. Holds state, exposes it via a render function.
class Counter extends Component {
constructor(props) {
super(props);
this.state = { count: props.initial || 0 };
this.increment = () => this.setState((s) => ({ count: s.count + 1 }));
}
render() {
const renderFn = this.props.render || this.props.children;
if (typeof renderFn !== 'function') return null;
return renderFn({ count: this.state.count, increment: this.increment });
}
}
// Usage. The consumer is no longer a separate component; it is a function
// passed inline. The state from Counter flows through that function's args.
const counter = new Counter({
initial: 10,
render: ({ count, increment }) => ({
type: 'button',
props: { onClick: increment },
children: ['clicks: ' + count],
}),
});
const first = counter.render();
console.log('first render:', first.children[0]);
counter.increment();
console.log('after increment:', counter.render().children[0]);
// Function-as-children variant. Identical behaviour, different prop name.
const counterChildren = new Counter({
initial: 0,
children: ({ count, increment }) => ({
type: 'span',
props: {},
children: ['fac=' + count + ' (next: ' + (increment ? 'wired' : 'not wired') + ')'],
}),
});
console.log('children variant:', counterChildren.render().children[0]);Render props express the same data flow as a HOC but at the call site. You write <Counter render={({ count, increment }) => <Button onClick={increment}>{count}</Button>}> and the function-as-prop is what consumes the injected state. The function-as-children variant (the second example) is more ergonomic in JSX because you can write <Counter>{({ count }) => ...}</Counter> and the JSX nesting reads naturally. The pattern is more flexible than HOCs because the consumer can pull only the props it wants out of the destructured argument and the wrapper hierarchy stays flat, but the inline function is a fresh allocation per render, which is the entire reason later libraries pivoted to hooks once those landed.
// A useful HOC variant that maps and filters props on the way down. Used for
// theming layers, feature flags, or compatibility shims where the wrapped
// component expects a slightly different prop shape than what the parents
// actually pass. The proxy is also where you would add prop-collision
// detection in a real production HOC.
class Component {
constructor(props) { this.props = props || {}; }
}
// Generic props-proxy. Takes a transform fn that runs over the incoming props
// and returns the props the inner component should receive. Optional filter
// drops keys you do not want to forward at all (analytics props that should
// stay at this layer, etc.).
function withPropsProxy(Wrapped, transform, filter) {
return class PropsProxy extends Component {
render() {
const incoming = this.props;
// Apply transform first so it can read the original props, then
// filter out keys we do not want to forward (component-level
// concerns like `variant` or `featureOff`).
const merged = transform ? transform(Object.assign({}, incoming)) : Object.assign({}, incoming);
const passing = {};
for (const key of Object.keys(merged)) {
if (filter && filter(key, merged[key])) continue;
passing[key] = merged[key];
}
const inner = new Wrapped(passing);
inner.props = passing;
return inner.render ? inner.render() : ('Wrapped(' + JSON.stringify(passing) + ')');
}
};
}
class Button extends Component {
render() {
return {
type: 'button',
props: { className: this.props.className, disabled: this.props.disabled },
children: [this.props.label],
};
}
}
// Theming proxy: turns variant=primary into className=btn-primary, drops
// theme so it does not bleed onto the DOM.
const ThemedButton = withPropsProxy(
Button,
(props) => Object.assign({}, props, {
className: 'btn-' + (props.variant || 'default') + (props.theme ? ' theme-' + props.theme : ''),
}),
(key) => key === 'variant' || key === 'theme' // drop these from forwarding
);
const primary = new ThemedButton({ variant: 'primary', theme: 'dark', label: 'Save' });
const tree = primary.render();
console.log('themed button class:', tree.props.className);
console.log('label:', tree.children[0]);
console.log('variant/theme were not forwarded (no theme prop on inner):', !('variant' in tree.props), !('theme' in tree.props));
// Feature-flag proxy: disable the button if a flag is set.
const FlaggedButton = withPropsProxy(
Button,
(props) => Object.assign({}, props, {
disabled: props.disabled || props.featureOff === true,
}),
(key) => key === 'featureOff'
);
const flagged = new FlaggedButton({ featureOff: true, label: 'Beta only' });
console.log('flag-disabled tree:', JSON.stringify(flagged.render()));Props proxies are the underrated HOC variant: they exist because not every wrapper wants to inject state, sometimes the only job is to map an incoming prop shape into the shape the inner component already understands. Theming layers are the canonical use case (<ThemedButton variant="primary" theme="dark" /> becomes a <Button className="btn-primary theme-dark" /> on the DOM), and feature-flag shims that disable interactive elements when a flag is on are the second canonical case. The filter callback is what stops noise leaking through to the DOM: variant and theme are component-level props, not HTML attributes, so we drop them on the way down rather than letting React warn at runtime. In modern code I would write this as a wrapper component plus props destructuring, but the HOC version still exists in any codebase old enough to have a styled-components v3 dependency.
// The HOC trap: passing a ref through a wrapper does not work by default.
// Refs are not props in the React sense, and a wrapper that just forwards
// `props` will silently swallow any ref the parent attaches. The fix is
// React.forwardRef inside the HOC, with the inner component accepting the
// ref as a regular argument.
class Component {
constructor(props) { this.props = props || {}; }
}
// Tiny stand-in for React.forwardRef. The real one returns a special
// component type; we just return a function that takes (props, ref).
function forwardRef(renderFn) {
return function ForwardedComponent(props, ref) {
return renderFn(props || {}, ref);
};
}
// withLogging is a wrapper that adds a console.log on every render (the
// canonical "add an aspect" HOC). It also needs to forward refs cleanly.
function withLogging(Wrapped) {
function Inner(props, ref) {
if (typeof console !== 'undefined') console.log('render', Wrapped.name || 'anonymous');
const enhanced = Object.assign({}, props, { ref });
const instance = new Wrapped(enhanced);
instance.props = enhanced;
// In real React, the ref is attached when the inner renders to a DOM node.
// We mimic that by writing the instance into ref.current.
if (ref && typeof ref === 'object') ref.current = instance;
return instance.render();
}
return forwardRef(Inner);
}
class TextInput extends Component {
focus() { this._focused = true; }
render() {
return { type: 'input', props: { value: this.props.value }, children: [] };
}
}
const LoggedInput = withLogging(TextInput);
// Parent attaches a ref to the wrapper. Without forwardRef, this would be null.
const inputRef = { current: null };
LoggedInput({ value: 'hello' }, inputRef);
console.log('ref.current is the inner instance:', inputRef.current instanceof TextInput);
console.log('we can call its imperative methods:', typeof inputRef.current.focus);
inputRef.current.focus();
console.log('focus was applied to inner, parent never touched the wrapper:', inputRef.current._focused);
// What happens without forwardRef (the bug). The wrapper just ignores the
// ref argument, so parent.ref stays null.
function brokenWrap(Wrapped) {
return function BadWrapper(props /* no ref! */) {
const instance = new Wrapped(props);
instance.props = props;
return instance.render();
};
}
const BrokenInput = brokenWrap(TextInput);
const brokenRef = { current: null };
BrokenInput({ value: 'world' });
console.log('without forwardRef, parent ref stays null:', brokenRef.current);Anyone who has inherited a 2018-era component library has hit this exact bug: a parent attaches a ref to a <LoggedInput ref={inputRef} /> and inputRef.current is null at runtime. Refs are not props, so a wrapper that only spreads props swallows them. React.forwardRef is the official escape hatch: the wrapper accepts (props, ref) and forwards the ref to the inner component, where it lands on whatever DOM node or class instance was the original target. The contract is mechanical, but it is invisible until you need it, and it shows up in any HOC that wraps an input, a button, a focus manager, or anything else parents might want to call imperative methods on. In modern code you can usually use useImperativeHandle instead, but legacy class-based HOCs still need this exact wrapping.
