Replacing Recompose with React Hooks
Recompose is a React utility belt for function components and higher-order components that has been very useful to our frontend engineering team. After more than three years of working with it, we’ve identified a lot of pain points. In October 2018, the React team introduced Hooks which shipped with React v16.8 and provided an alternative to HOCs. After a lot of discovery, discussion, and reading into discussions like this, we recently made the decision to remove Recompose entirely – which was a large endeavor. It touched over 200 files, and in some places was deeply coupled to core functionality of our app.
This post covers why we decided to replace Recompose. For implementation details, see the next post: Decompose Recompose: How to remove Recompose and replace with Hooks
Why we added Recompose
React encourages creating reusable components. However, it didn’t offer any great solutions to share logic between those components. At the time, React was transitioning away from using mixins and trending towards a Higher-Order Component (HOC) based approach.
HOCs offered a solution to create reusable logic that could be applied to “dumb”functional components to make them “smart.” This separated the presentational code (i.e. the JSX) from the logic code, and created more flexibility by allowing you to combine these in different ways.
Recompose offered out-of-the-box HOCs that did pretty much everything we needed, and made it simple to compose them in various ways. It also enabled us to make quick changes without having to refactor components. For example, a stateless functional component could quickly have state and handlers by simply wrapping it with some HOCs instead of refactoring it into a class component.
After working with Recompose for some time, we found it to be powerful and flexible while addressing our needs. We soon created a bunch of reusable custom HOCs that we layered together with what Recompose offered.
The challenges with Recompose
Readability, onboarding, and velocity
A large selling-point for React is that it enables devs to move very quickly, especially when introduced to a large and unfamiliar code base. With its declarative approach to building user interfaces, it makes it simple to reuse existing components, understand how they work, and to be able to modify them.
Readability is a very important aspect of determining the quality of any codebase. After using Recompose for while, it became obvious that it was negatively affecting the readability of our codebase. This surfaced mainly during onboarding of new devs – especially junior devs.
Here’s a simple example of an enhance HOC that just handles opening and closing a popover:
Unless you’re a Recompose ninja, this is definitely not fun to try to pull apart.
Simply put, Recompose is not a library that most React devs are familiar with. This creates a very steep onboarding ramp for new additions to your dev team and eats into productivity for everybody – additional pairing sessions, code reviews, etc become necessary. It eats into your product/engineering department’s ability to move quickly and implement new features. While our senior devs are highly effective with Recompose, it became more of hindrance than a useful tool because we were constantly spending time helping junior devs with Recompose related issues.
DOM bloat and debugging via React DevTools
One downfall of the HOC approach is that it requires nesting components. With Recompose, this means possibly nesting dozens of components to achieve relatively simple functionality. While React DevTools has rolled out some nice updates recently to handle these scenarios, it still results in some unwieldy HOC messes.
This screenshot comes from DevTools while inspecting an icon that opens a “are you sure you want to delete this?” modal and then deletes the item once you confirm. It makes sense to use HOCs in many situations, but nesting many of them leads to debugging challenges. Our more complex top-level components often had muchlarger trees than this simple component does.
Here’s a fun example of a larger one – and most of the tree doesn’t even fit in the screenshot!
By abstracting functionality into HOCs, we often create variables and/or state that the component does not need to know about. Some HOCs also provide extra props(props that are only used internally in the HOCs and are not needed by the component to render) that can sneak into your component (like withRouter for example). A simple solution is to filter out intermediate props at the end of your composition using mapProps (thus making it the “lowest order” HOC). It looks something like:
This filters out any unnecessary props from the higher HOCs. But adding an additional HOC to all of your compositions is not a sustainable solution.
mapProps also allows for renaming of props, which is useful for matching APIs of third-party components. For example:
Notice that this example uses the spread operator …props to pass along all the rest of the props without needing to specify them individually. While this is a handy trick, it’s a large culprit of component receiving unneeded props. This is a problem when rendering elements in general – not an HOC specific problem – but it exacerbates the problem of HOCs producing intermediate props and makes it very easy for them to be passed down to the child component. All these prop challenges associated with the composed HOCs leads to another not-so-fun challenge: static type checking.
Static type checking became a huge pain for us. The pain was not caused exclusively by Recompose, but rather from the interaction of Flow + Recompose. This is a general problem with third party HOCs – not just Recompose.
If you don’t annotate something that’s not a Recompose HOC, Flow will certainly complain about missing annotations. However, missing typings for those HOCs are largely ignored because Flow doesn’t know enough about Recompose to properly type check it.
The solution was to manually annotate every instance of the HOC, which was a terribly cumbersome task. For HOCs that inject props, you have to type the component so that flow takes it into account. In other words, you quickly lose all the stuff you get for “free” from Flow out of the box
There are typing solutions that exist for Recompose, but by the time they were fleshed out it was too little too late for us. Adding it to our codebase immediate threw an ungodly amount of Flow errors, so we decided it was not worth attempting. Additionally, the package had bugs that negatively affected our experience. As fixes and updates were rolled out, we often allowed ourselves to fall behind the most recent version since upgrading was not a major priority for us. Falling further behind meant that we had a more painful upgrade path, which often deterred us from upgrading. Having to work around these bugs meant adding lots of $FlowFixMe comments -or sometimes not annotating a file at all. Checking out their GitHub issues shows a steady flow of bugs.
Testing components with lots of HOCs
We use a standard Jest + Enzyme setup to run our test suite. Testing components that are deeply nested in HOCs has some challenges. While they are not insurmountable, they can be cumbersome and annoying.
HOCs cause snapshots to quickly balloon. This means either targeting the actual component and eliminating HOCs in the snapshot with something like:
or if you find the HOCs important to the overall context of the snapshot, then you end up with gigantic snapshots (very similar to the React DevTools annoyance). This seems like a relatively harmless hurdle, but it can become a pain when trying to snapshot complex HOCs compositions.
Consider the example:
This enhance pattern is prevalent throughout our codebase. Assume in this example that enhance has a bunch of complicated logic. This leads to the question of how to properly test it. The 2 main approaches are:
- test the default exported component, which is the component wrapped in the entire enhance
- test the component and the enhance separately
I won’t make a case for which is the “best” approach, but different components often call for different testing approaches based on size, complexity, and functionality. While it’s reasonable to create best practices for this, we found a lot of gray areas which prevented us from creating hard and fast rules. The end result was a combination of the two approaches and many judgement calls on the side the implementer.
HOCs are still powerful tools!
After all this ranting about pain points, it’s important to remember that this article is about removing Recompose, not HOCs entirely. HOCs are still very powerful for abstracting away reusable logic and make it easy to add this to any of your components.
Throughout our removal of Recompose, we found that we preferred to replace almost all of our HOCs for consistency, readability, and reduced complexity. Many projects are making their own custom hooks available as well. You’ll need to decide what makes sense for your situation.
Where we went wrong
Recompose offers tradeoffs just like every other library. It addressed the problems we initially set out to resolve, but also created many more challenges. These challenges were largely compounded by our overuse of it. It does something very cool, but very dangerous – it makes you feel clever.
By “feel clever” I mean that it allows you to create complex functionality with a very small amount of code, abstract it away (maybe put it in a separate file from your component), and then feel like a magician. This leads to two very serious problems:
- Other devs don’t know how your magic works
- Feeling clever and magical is a slippery slope to “make everything magic!”
Feeling clever is often a false reinforcement that you are a good dev and teammate. In reality, feeling clever is a bad smell. This, combined with our desire to create strong conventions, led to us massively overusing Recompose. While it’s definitely a powerful tool, it is not a silver bullet and should not be used for all components.
Our overuse of Recompose led to unneeded complexity and exacerbated the readability + onboarding problems mentioned earlier. Even worse, it made the removal of Recompose significantly more difficult since it created a lot of complex technical debt.
Our up-front evaluation of the library was not sufficient. In hindsight, there were some shortcomings.
Consideration of interaction with other libraries: More thought should have been given to the implications of adding Recompose and how it would affect existing libraries and our ability to use them. Specifically, the interaction of Recompose <> Flow has been a major pain. This was largely due to our insufficient type checking and lack of staying up-to-date with new Flow versions. It makes sense that we missed this up-front, but after adding Recompose to a handful of components, we should have identified this hurdle and dealt with it before composing all the things.
Conclusion and learnings
There are no silver bullets – don’t overuse libraries/tools
When you’re a hammer, everything looks like a nail. Recompose offers a lot of power and is quite useful, but that does not mean it should be applied everywhere. Code convention is not necessarily a good reason to uniformly apply a particular approach in all situations. Instead, use the tool where it is necessary and constantly ask yourself“do I need this here?”
Don’t get sucked into feeling clever
Recompose allows you to pack a lot of logic and functionality into a few lines of code. At the same time, it allows you to remove all of that code from the component and sequester it away into a big HOC. As engineers, we strive to write “better” code – but this is often misinterpreted as writing as little code as possible.
When you refactor a lot of code into a little code, you feel clever – you’ve done more (or at least the same) with less code. This sounds like a good thing, but in practice it reduces readability and productivity, while increasing the slope of the learning curve for anybody else that wants to understand that code.
Strive for readability and productivity. If you feel like you’ve done something clever, ask yourself if it’s really the best approach in the long run.
Evaluate the future of your libraries and tools
- who maintains this project? Is there a large organization backing it, or an individual?
- how active is the project?
- will there be a need for this in the future?
- can it easily be replaced or made obsolete?
- how hard will it be to remove it in the future?
- does the project have a roadmap for future improvements?
- will the project continue to support new versions of core libraries (like React)?
Instead of just determining if it’s the right tool for the job at hand, consider the implications of using this tool in the long run. It’s difficult to pick winners and losers, but aligning yourself with well-maintained projects and the opinions of the ecosystem as a whole is a relatively safe bet.
For a detailed explanation on how we implemented the removal of Recompose, check out our post: Decompose Recompose: How to remove Recompose and replace with Hooks.