How to Convert React Hooks into HOCs for Legacy Components
Since their introduction in React 16.8, hooks have rapidly gained popularity among developers as a more intuitive and flexible way to add stateful logic to components. By allowing you to "hook into" lifecycle features like state and side effects from function components, hooks enable cleaner, more modular code compared to the previous patterns of mixins and higher-order components (HOCs).
However, hooks aren‘t a complete replacement for class components just yet. Many established React codebases still heavily use classes, and certain advanced features like error boundaries still require them. Plus, if you‘re authoring a library designed to work across multiple React versions, you can‘t assume your consumers have hooks available.
In these cases, it‘s useful to know how to convert a hook into a more traditional HOC that can wrap class components. This article will explore several approaches to making hooks compatible with classes, along with their benefits and drawbacks.
The Evolution of Sharing in React
To understand why hooks and HOCs solve similar problems, it‘s helpful to look at how React has evolved its component model over time. In the early days of React, the primary way to share stateful logic between components was through mixins – a way to extend a component‘s prototype with additional properties and methods.
const subscribeMixin = {
componentDidMount() {
this.unsubscribe = this.props.subscribe();
},
componentWillUnmount() {
this.unsubscribe();
}
};
class MyComponent extends React.Component {
// ...
}
Object.assign(MyComponent.prototype, subscribeMixin);
However, as codebases grew larger, mixins became unwieldy due to issues like implicit dependencies, name clashes, and snowballing complexity. In response, the React team introduced higher-order components as a more declarative way to compose behavior.
An HOC is simply a function that takes a component and returns a new component with some additional props or lifecycle logic. This pattern became widely used for tasks like fetching data, managing subscriptions, and injecting context.
function withSubscribe(WrappedComponent) {
return class extends React.Component {
componentDidMount() {
this.unsubscribe = this.props.subscribe();
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
return <WrappedComponent {...this.props} />;
}
}
}
class MyComponent extends React.Component {
// ...
}
export default withSubscribe(MyComponent);
But even with HOCs, React‘s component model still had some inherent limitations. Classes made it difficult to share stateful logic without complex hierarchies or prop drilling. They also didn‘t minify well and suffered from confusing quirks around the this
keyword.
Hooks were introduced to address these drawbacks by allowing you to extract stateful logic into reusable functions that can be composed together. Rather than wrapping components, hooks let you "hook into" state and lifecycle features from inside a component‘s body.
function useSubscribe() {
const [, forceUpdate] = useState({});
useEffect(() => {
const unsubscribe = props.subscribe(() => {
forceUpdate({});
});
return () => unsubscribe();
}, [props.subscribe]);
}
function MyComponent(props) {
useSubscribe();
// ...
}
Hooks Adoption in the React Community
Since their stable release in early 2019, React hooks have seen rapid adoption across the ecosystem. According to npm download statistics, the react-dom
package that includes hooks has averaged over 8 million downloads per week in 2020.
The 2019 State of JavaScript survey found that 46% of React developers were already using hooks in production, with another 39% planning to learn them. Satisfaction levels were also extremely high, with 87% of respondents saying they would use hooks again.
However, despite hooks‘ benefits, many teams still have significant class component codebases that will take time to fully migrate. And according to the React docs, there are no plans to deprecate classes or stop supporting them in the future.
For the foreseeable future, React will include both function components with hooks and class components for backward compatibility. So it‘s important for shared libraries and tooling to be able to bridge the gap by converting hooks to class-compatible HOCs.
Manual HOC Conversion Examples
As shown earlier, the basic pattern for converting a hook to an HOC is to create a wrapper function component that calls the hook and passes its values as props to the wrapped class component. Let‘s walk through a few more complex examples.
Converting the useReducer Hook
The useReducer hook is an alternative to useState for more complex state logic, similar to Redux reducers. It takes a reducer function and initial state, and returns the current state along with a dispatch function to trigger updates.
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case ‘increment‘:
return { count: state.count + 1 };
case ‘decrement‘:
return { count: state.count - 1 };
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: ‘increment‘ })}>+</button>
<button onClick={() => dispatch({ type: ‘decrement‘ })}>-</button>
</>
);
}
To convert useReducer to an HOC, we need to initialize the reducer state in the wrapper component and pass the current state and dispatch function down as props.
function withReducer(reducer, initialState) {
return function(WrappedComponent) {
return function(props) {
const [state, dispatch] = useReducer(reducer, initialState);
return <WrappedComponent {...props} state={state} dispatch={dispatch} />;
}
}
}
class Counter extends React.Component {
render() {
const { state, dispatch } = this.props;
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: ‘increment‘ })}>+</button>
<button onClick={() => dispatch({ type: ‘decrement‘ })}>-</button>
</>
);
}
}
export default withReducer(reducer, initialState)(Counter);
Now the class component can access the current state and dispatch actions just like the function component version.
Converting Hooks with Dependencies
Some hooks, like useEffect and useCallback, take an array of dependencies as the last argument. These dependencies are used for performance optimizations to avoid unnecessary recalculations.
function Example(props) {
const { data } = props;
const memoizedCallback = useCallback(() => {
doSomething(data);
}, [data]);
useEffect(() => {
fetchData(data.id);
}, [data.id]);
// ...
}
When converting these hooks to HOCs, you‘ll need to pass the dependencies from the wrapper component to ensure they trigger updates appropriately.
function withCallbackAndEffect(WrappedComponent) {
return function(props) {
const { data } = props;
const memoizedCallback = useCallback(() => {
doSomething(data);
}, [data]);
useEffect(() => {
fetchData(data.id);
}, [data.id]);
return <WrappedComponent {...props} callback={memoizedCallback} />;
}
}
class Example extends React.Component {
// ...
}
export default withCallbackAndEffect(Example);
The key is to destructure the dependency props in the wrapper component so they‘re included in the hook dependency arrays. This ensures the memoizations and effect callbacks will update when the corresponding props change.
Testing HOC-Wrapped Components
One potential gotcha of converting hooks to HOCs is that it can make testing more complicated. With hooks, you can easily mock out values with a library like react-hooks-testing-library
to unit test a single function component.
import { renderHook, act } from ‘@testing-library/react-hooks‘
test(‘should increment counter‘, () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
But with an HOC-wrapped class component, you now have to deal with shallow rendering the wrapper component and diving into the wrapped instance to test its props.
import { shallow } from ‘enzyme‘;
test(‘should pass count state and increment dispatcher‘, () => {
const wrapper = shallow(<withCounter(TestComponent) />);
const wrappedInstance = wrapper.dive();
expect(wrappedInstance.prop(‘count‘)).toBe(0);
wrappedInstance.prop(‘increment‘)();
expect(wrappedInstance.prop(‘count‘)).toBe(1);
});
This isn‘t necessarily a dealbreaker, but it‘s something to be aware of when deciding whether to convert a hook to an HOC. In general, I would recommend extracting the hook logic into a separate function that can be tested in isolation, then unit testing the wrapped component‘s rendering output and prop passing.
Automating the Conversion Process
As we‘ve seen, converting a hook to an HOC by hand is fairly straightforward but involves repeatedly writing a lot of boilerplate code. Luckily, there are libraries to help automate this process and reduce the verbosity.
The most full-featured option is rehoc, which provides a rehoc
function to wrap a hook in an HOC and automatically forward refs for functional and React.forwardRef
components. It also offers utilities for hoisting non-React static properties from the wrapped component.
import rehoc from ‘rehoc‘;
function useState(initialState) {
const [state, setState] = React.useState(initialState);
return { state, setState };
}
const withState = rehoc(useState);
class Example extends React.Component {
render() {
const { state, setState } = this.props;
// ...
}
}
export default withState({ count: 0 })(Example);
If you only need a simple hook-to-HOC converter without the extra utilities, the hocify
library shown earlier is a lightweight alternative. And if you find yourself frequently writing similar HOCs, you can use react-hoc-generator to quickly scaffold them from a configuration object.
Ultimately, the approach you choose will depend on your specific needs and how much complexity you want to take on. But by leveraging these tools, you can significantly reduce the boilerplate needed to make your custom hooks compatible with legacy class components.
Conclusion
React hooks have unlocked a more expressive and flexible model for component-based UI development. By allowing you to extract stateful logic into reusable functions, they enable more natural composition and code reuse compared to previous patterns like mixins and render props.
However, hooks don‘t mean the end of class components just yet. Many established React codebases still rely heavily on classes, and certain advanced features like error boundaries aren‘t (yet) available in hooks.
For teams that want to adopt hooks while still supporting legacy components, converting hooks to higher-order components is a useful technique. By wrapping a hook in a function component and passing its return values as props, you can make it compatible with class component lifecycles.
In this article, we‘ve explored several approaches to hook-to-HOC conversion, from manually writing wrapper components to using libraries that automate the process. We‘ve also discussed some of the potential benefits and drawbacks of this pattern, like increased complexity and challenges around testing.
Ultimately, the decision to convert hooks to HOCs depends on your team‘s specific needs and constraints. If you‘re building a shared library that needs to be compatible with multiple React versions, or gradually migrating a large class component codebase, then this technique can be a valuable tool.
But in general, I would recommend adopting hooks directly whenever possible, especially for new code. Hooks represent a significant step forward for React‘s component model and are quickly becoming the standard across the ecosystem.
Looking ahead, I expect we‘ll see more tools and best practices emerge to smooth the transition from classes to hooks. Already, libraries like react-compat aim to make it easier to write code that runs in both models.
It‘s an exciting time to be a React developer as we explore new patterns and possibilities unlocked by hooks. While there may be some short-term friction, the long-term benefits of a more compositional, decoupled component model are clear. By understanding the interoperability between hooks and classes, we can build more resilient, future-proof codebases.