In our last post, we explored the pros and cons of Recompose and why we decided to remove it from our codebase. This post includes the strategy we used to approach the large task of implementing that refactor. It’s important to note that this strategy was created to fit our specific situation and is not a one size fits all approach to removing Recompose. Specifically, it was intended to work with our large codebase that is modified by our devs daily. It was very important to not break things(obviously… but this is magnified by the fact that we are a QA solution) and to minimize code conflicts and disruptions to other engineers. It’s important to create your own implementation strategy, but hopefully ours will serve as a useful guideline.
Implementation Strategy
First, identify all files that use Recompose. This is as simple as grepping your code base for “recompose”. We took this list of files and turned it into a checklist. We used this checklist to avoid any conflicts because other devs could be touching these files. We then went one-by-one and removed Recompose from a single file (on rare occasions, it needed to be removed from multiple due to them being tightly coupled). This produced small pull requests that only touched one (or a few) files, meaning bite-sized code reviews.
At any given time, there was only one dev working on refactors. When they completed the refactoring of a component, it was checked off the list.
Tackle simple refactors first (low-hanging fruit). Taking the simple refactors out first gets the momentum going and allows for more flexibility and experimentation in how exactly you want to refactor. It’s also nice to get some experience under your belt before taking on the complicated ones.
Next, attack highly-reused components. These have the longest reaching effect, as they are used by various other components and contribute the most to the “DOMbloat” and potentially to bloated snapshots.
Now for the meat of the implementation – actually refactoring components!
First, you need to decide the end goal of your refactor. The biggest question we debated was whether to refactor everything into class components or into hooks. Perhaps there is a middle ground? Referring back to the section on “what went wrong” in the last post, you’ll recall that our major downfall was going overboard with Recompose. Just because you can use Recompose doesn’t mean you should. The same argument could be made for hooks – should I always use hooks and just do away with class components?
Ultimately, that is a question you must decide for yourself. We’ve decided to lean towards hooks with a healthy dose of skepticism after being burned by our overuse of Recompose. I won’t go into a deep dive on this, but the TLDR is to read the Hooks FAQ and decide what’s best for you team. Our main takeaway was that hooks are the future of React, so it’s safe to get onboard with them.
For the sake of this article, we’ll be refactoring everything into hooks and avoiding class components completely.
Guidelines for refactoring a component
The examples below address specific HOC => translations. In reality, your components will likely have multiple HOCs composed together. It depends on the complexity of these HOCs, but in general you’ll want to start with the “lowest”component and work your way up.
For example, consider:
const Foo = () => {
return <div>...</div>;
};
const WrappedComp = compose(
hocA,
hocB,
hocC,
hocD,
hocE
)(Foo);
Props propagate from hocA down to hocE. This means we can convert hocE while leaving the others in place:
const Foo = () => {
// imaginary hook/function to replace hocE's logic
return <div>...</div>;
};
const WrappedComp = compose(
hocA,
hocB,
hocC,
hocD
)(Foo);
This allows you to incrementally refactor away the HOCs without breaking things. For example you can refactor a single HOC, run your specs to ensure no breakages, and then move onto the next. If you have highly complex HOCs like we did, you will likely run into some cases where this is unfortunately not possible. But it stands as a good rule of thumb.
There are some direct HOC => hook translations that are easy to implement:
withState => useState
withHandlers => useCallback
(or a regular function)withStateHandlers => useState + useCallback
withReducer => useReducer
withContext => useContext
withPropsOnChange => useMemo
- lifecycle methods
componentDidMount => useEffect
componentDidUpdate => useEffect
componentWillUnmount => useEffect
Other HOCs do not have direct replacements, so we’ll touch of some of those later.
Let’s look at some examples of HOCs that are easily replaced with hooks.
withState
withState makes it simple to add state with an updater function. useState has a very similar signature, as both accept an optional initial state and return the current state + a handler to update the state.
// Recompose
const _ToggleButton = ({ isOpen, setIsOpen }) => (
<div>...</div>
);
const ToggleButton = withState('isOpen', 'setIsOpen', false)(_ToggleButton);
// Hooks
const ToggleButton = () => {
const [isOpen, setIsOpen] = useState(false);
return <div>...</div>>;
};
withHandlers
withHandlers is easily replaced by useCallback, or simply with a regular function. Using useCallback can be unnecessary, a premature optimization, or could possibly even perform poorly compared to a regular function. If referential equality is important, then useCallback is a great idea. If not, you’re probably better off with a regular function. You can find a more thorough explantation in this blog post.
// Recompose
const _Button = ({ onClick }) => <button onClick={onClick}>Click Me</div>;
const Button = withHandlers({
onClick: () => () => {
// do stuff
}
})(_Button);
// Hooks
const Button = () => {
const onClick = useCallback(() => {
// do stuff
});
return <button onClick={onClick}>Click Me</div>;
}
// No Hook
const Button = () => {
const onClick = () => {
// do stuff
};
return <button onClick={onClick}>Click Me</div>;
}
withStateHandlers
withStateHandlers is replaced by a combination of useState and useCallback. In this case, we could probably just use a regular function – but we’ll use the hook.
// Recompose
const _Input = ({ value, onChange }) =>
<input type="text" value={value} onChange={onChange} />;
const Input = withStateHandlers(
{ value: '' },
onChange: ({ value }) => () => ({ value });
)(_Input);
// Hooks
const Input = () => {
const [value, setValue] = useState('');
const onChange = useCallback(({ value }) => {
setValue(value);
});
return <input type="text" value={value} onChange={onChange} />;
}
componentDidMount, componentDidUpdate, and componentWillUnmount
These lifecycle methods are easily replaced with useEffect. Notice that in the componentDidUpdate example, the omission of the dependency array causes the function to be invoked on every render.
// componentDidMount
useEffect(() => {
// this code executes on mount only
}, []);
// componentWillUnmount
useEffect(() => {
// this code executes on mount only
return () => {
// this code executes on unmount only
}
}, []);
// componentDidUpdate
useEffect(() => {
// this code executes on EVERY render
return () => {
// this code executes on unmount only
}
})
Important note! These are definitely contrived examples. In practice, you won’t want to declare an empty array of dependencies. Thinking in terms of synchronization with effects is also subtly different from the mount/update/unmount mental model. I highly recommend reading Dan Abramov’s post (linked above) for a deep-dive into useEffect.
componentWillReceiveProps
Replacing componentWillReceiveProps is a bit of a pain – there’s no hook equivalent, and comparing incoming props to previous props requires a bit of a workaround.
In order to access previous props, we can leverage the useRef hook. A great example of this is the usePrevious hook. We’ll use it to replace componentWillReceiveProps
const Foo = ({ number }) => {
const prevNumber = usePrevious(number);
useEffect(() => {
if (number !== prevNumber) {
// do something
}
}, [number]);
return <div>...</div>
}
Hopefully we’ll see usePrevious (or something that allows us to access previous props) as a native React hook in near future!
Some Recompose HOCs cannot be replaced by a single hook, but we can certainly still replace them with vanilla JS functions, React components, and/or custom hooks. Examples include mapProps, withProps, branch, and pretty much every other HOC.
It also turns out that the need for some of these HOCs is caused by the existence of other HOCs. We found that removing most of the HOCs meant that we no longer needed most instances of mapProps and withProps, which are largely used to manipulate props before they are passed down to their children.
Popular libraries are shipping their own hooks, such as React Redux and React-Router. While it’s not necessary to remove all of your HOCs, we found ourselves quickly moving away from them and replacing all HOCs.
Conclusion and Learnings
Have a strategy
It’s important to have a strategy for removing a library like Recompose before you jump in and start refactoring code. Break the refactor into manageable chunks. Depending on the architecture of your codebase, it’s very possible that there will be side-effects that you did not anticipate if you try to bite off more than you can chew.
Removing Recompose from most components is pretty straightforward. Complications arise from custom HOCs that contain complicated logic and/or are tightly coupled to other HOCs. Consider refactoring other HOCs into hooks, including replacing third party HOCs like connect (if your react-redux version makes useSelector and useDispatch available), but keep in mind that it is not completely necessary.
Ensure you understand hooks
Consider the subtle differences between thinking in terms of the “lifecycle” mental model and thinking in terms of synchronization with effects.
There are not a ton of best practices established around hooks yet, but Dan Abramov’s blog has a ton of great insight and I highly recommend digging into it before attempting to refactor into hooks.