Blogged Answers: Thoughts on React Hooks, Redux, and Separation of Concerns

This is a post in the Blogged Answers series.

Some observations on the tradeoffs involved in using React hooks

Intro πŸ”—︎

I've seen a lot of questions about React hooks usage lately, and React-Redux hooks specifically. The most common questions are things like:

  • "How do I test components that depend on an outside data source like a Redux store?"
  • "Doesn't use of hooks and context reduce separation of concerns?"
  • "What about the 'noise' from writing a bunch of hooks and callbacks inline in the component?"

I actually have a lot of thoughts on the tradeoffs that hooks bring. Earlier today, I wrote up a long Twitter thread with some of those thoughts, and I'd like to expand on that thread here.

Before I go further, some caveats. I have written some small example apps that use hooks, but I haven't used them seriously in a real-world app yet. Same goes for our new React-Redux hooks API in particular - I drove the effort to design and publish those APIs, but I've only briefly used them in action myself. I'm also not an expert on testing, either. I know plenty of theory, I just haven't actually written all that many tests myself (unit or integration). So, I will freely say up front that these are just some opinions and observations, I could easily be wrong, and you should make up your own mind on things.

Hooks, HOCs, and Tradeoffs πŸ”—︎

Since the minute they were announced, the React community has gone crazy over hooks. While there's been plenty of justified skepticism, large swathes of the community have jumped all-in on hooks, touting them as the "obvious way" to write React code going forward. As is usually the case, the flash and excitement should be tempered with some more realistic expectations.

In one sense, hooks don't give you anything new. Class components have always had state and side effects. Hooks "just" let you do the same thing in function components. In that sense, nothing has changed.

At the same time, hooks change things dramatically. Logic that was split across multiple lifecycles is now co-located. Hooks require use and knowledge of closures instead of this. Same results, but written quite differently. In particular, use of closures results in components that are much larger, because you have to write functions inline to capture variables from the scope.

As with any technique, this has tradeoffs. Colocation is generally good, but complex components can be very long. Some of that size can be alleviated by extracting custom hooks. (I guess you could do something similar with defining functions inline - have some factory like makeClickHandler(a,b, c) to move the actual code outside the component, but that starts to get kind of silly and over-abstracted.)

Because of this, hooks deliberately make different tradeoffs on the "separation of concerns" and "simplicity" spectrums than HOCs do:

  • HOCs promote writing plain components that receive all data as props, keeping them generic, decoupled, and potentially reusable. The downsides of HOCs are well known: potential name clashes, extra layers in the component tree and DevTools, extremely complex static typing, and edge cases like ref access.
  • Hooks shrink the component tree, promote extracting logic as plain functions, are easier to statically type, and are more easily composable. But, hooks usage does lead towards a stronger coupling with dependencies - not just for React-Redux usage, but any usage of context overall.

In that sense, hooks deliberately lead away from "separation of concerns". A component now explicitly expects to read its own data from somewhere and render it. You could write separate components for fetching and rendering, but now you've reinvented the HOC.

React-Redux Hooks Usage Patterns πŸ”—︎

The Redux team has always promoted "keeping components unaware of Redux":

They should simply receive data and functions as props, just like any other React component. This ultimately makes it easier to test and reuse your own components.

Now, it's not like our new hooks API completely changes things. We've always encouraged use of selector functions for extracting values from the store state - useSelector() just formalizes it a bit (and you could even reuse your existing mapState functions with useSelector(), assuming you either memoize the result or use shallowEqual as the comparison function).

Meanwhile, you've always been able to just do connect()(MyComponent), do some async logic in the component, and then explicitly this.props.dispatch(someAction). However, we've discouraged that on the grounds that it makes the async code less reusable.:

In general, Redux suggests that code with side effects should be part of the action creation process. While that logic can be performed inside of a UI component, it generally makes sense to extract that logic into a reusable function so that the same logic can be called from multiple placesβ€”in other words, an action creator function.

That's part of why action creators, thunks, and mapDispatch exist: to enable dispatching without explicit mention of dispatch at the call site, and to enable generic "presentational" components.

With our React-Redux hooks, though, you've only got useDispatch(). So, you always have that directly available. I can see folks potentially dropping thunks going forward, writing async logic in a useEffect(), and dispatching directly in the component. It may also make it easier to mix together React and Redux in some new ways by extracting custom hooks, as seen in this example of converting from connect() to hooks.

(As a side note: the whole "container/presentational" thing has always been over-interpreted by the community. Dan has since mostly disavowed his original post, and when we revamp the Redux docs, we'll rewrite the "React Usage" tutorials page to stop emphasizing that concept.)

So yes, our React-Redux hooks definitely couple the component more tightly to Redux, because that's basically true for any component that is using context rather than props. There's an implicit dependency on that context instance and the data being provided.

Conclusions πŸ”—︎

As with most of programming: it's not that either of these approaches is right or wrong. It's that these approaches have different tradeoffs, and each team is going to have to make their own decisions as to which tradeoffs are more important for them.

How much do you want to separate concerns? If you like "shallow" testing components, hooks may not be for you, as they really require "integration"-type tests. Do you prefer cleaner component trees, colocation, and easier static typing, or more separation and indirection?

Long-term, it's going to be interesting seeing the ramifications of this play out in the ecosystem. Hooks have only been out for a few months, so the community is still trying to work out how to best make use of them. It took us years to go from "mixins" to "HOCs", and then even figure out that "render props" were a thing. We'll be exploring hooks usage patterns for a long time.

One other thought: I've frequently observed that React and Redux users can be broadly grouped into two points of view: "app-centric" vs "component-centric" design. I think the questions around hooks tradeoffs tie into some of those mindset aspects. Is React your actual "app"? Or is it "just" the UI layer, with the real app being the logic and data kept outside the component tree? Both are very valid viewpoints, again with differing tradeoffs.

Further Info πŸ”—︎

Also see my ReactBoston 2019 talk on "Hooks, HOCs, and Tradeoffs", which discusses this topic further.

This is a post in the Blogged Answers series. Other posts in this series: