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 on how to replace Recompose with React 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:

const enhance = compose(
withStateHandlers(
  { isOpen: false },
  {
    closePopover: (_, { onClose }) => () => {
      if (onClose) {
        onClose();
      }
      return { isOpen: false };
    },
    onToggle: (
      { isOpen },
      { onClose = () => null, onOpen = () => null }
    ) => () => {
      if (isOpen) {
        onClose();
        return { isOpen: false };
      }
      onOpen();
      return { isOpen: true };
    },
    onOuterAction: ({ isOpen }, { onClose, isValidOuterAction }) => ev => {
      if (!isOpen || (isValidOuterAction && !isValidOuterAction(ev))) {
        return {};
      }
      if (onClose) onClose();
      return { isOpen: false };
    }
  }
),
mapProps(props => omit(props, ['isValidOuterAction']))
);

Unless you’re a Recompose ninja, this is definitely not fun to try to pull apart.

Thinking in React is a popular article used to introduce React noobs to its way of thinking. For devs that come from other popular JavaScript frameworks, this is a paradigm shift and can take some time before they feel comfortable. Recompose adds an additional layer of complexity – enough so that I would argue it calls for a new Thinking in Recompose paradigm.

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!

major tree depth!

Unneeded props

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:

const enhance = compose(
withRouter,
withSomething,
withElse,
mapProps(({ foo, bar, ...rest }) => ({ foo, bar }))
)

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:

const enhance = compose(
// ... other HOCs ...
mapProps(({ onToggle, ...props }) => ({ onChange: onToggle, ...props }))
)

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.

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:

expect(comp.find(‘Foo’)).toMatchSnapshot();

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.

A larger challenge is around how things should be tested. In general, HOCs add complexity to specs. See this enzyme issue as well as this one for some examples.

Consider the example:

const Comp = ({ ...props }) => foo;
const enhance = compose(connect(mapStateToProps),
  withRouter,
  withFoo,
  withBar,
  withState,
  withHandlers(...)
  mapProps(...)
  );

export default enhance(Comp);

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:

  1. test the default exported component, which is the component wrapped in the entire enhance
  2. 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

HOC Overkill

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:

  1. Other devs don’t know how your magic works
  2. 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.

Evaluation

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.

Lack of foresight/future proofing: This is a common challenge in the JavaScript ecosystem, and is certainly not isolated to Recompose. It seems like every week brings a new shiny tool, library, framework, method of thinking, and so much more. It’s easy to get swept up in the latest thing, but it’s a dangerous game to play. As we’ve learned from the introduction of Hooks, Recompose is now an obsolete tool. For libraries that can be introduced non-invasively (i.e. just add it to a few components initially), it’s a good idea to have a mid to long term evaluation strategy. You’re likely to overlook potential pitfalls when you’re playing with your shiny new toy, so it’s important to periodically step back and re-evaluate if the tool is bringing more value than baggage.

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

In the incessantly shifting JavaScript ecosystem, there is always a new and shiny tool that comes with promises of greener pastures. It’s important to evaluate the future of the tool to determine if it will continue to be relevant as the ecosystem pushes forward. Questions to ask include:

  • 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.