Some observations on the tradeoffs involved in using React hooks
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.
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.
This is a post in the Blogged Answers series. Other posts in this series:
- Aug 02, 2017 - Blogged Answers: Webpack HMR vs React-Hot-Loader
- Dec 18, 2017 - Blogged Answers: Resources for Learning React
- Dec 18, 2017 - Blogged Answers: Resources for Learning Redux
- Mar 29, 2018 - Blogged Answers: Redux - Not Dead Yet!
- Jan 19, 2019 - Blogged Answers: Debugging Tips
- Jul 10, 2019 - Blogged Answers: Thoughts on React Hooks, Redux, and Separation of Concerns