Hooks burst onto the scene with the release of React 16.8 with the lofty goal of changing the way we write React components. The dust has settled, and Hooks are widespread. Have Hooks succeeded?
The initial marketing pitched Hooks as a way of getting rid of class components. The main problem with class components is that composability is difficult. Resharing the logic contained in the lifecycle events componentDidMount
and friends led to patterns such as higher-order components and renderProps
that are awkward patterns with edge cases. The best thing about Hooks is their ability to isolate cross-cutting concerns and be composable.
What Hooks do well is encapsulate state and share logic. Library packages such as react-router
and react-redux
have simpler and cleaner APIs thanks to Hooks.
Below is some example code using the old-school connect
API.
import React from 'react'; import { Dispatch } from 'redux'; import { connect } from 'react-redux'; import { AppStore, User } from '../types'; import { actions } from '../actions/constants'; import { usersSelector } from '../selectors/users'; const mapStateToProps = (state: AppStore) => ({ users: usersSelector(state) }); const mapDispatchToProps = (dispatch: Dispatch) => { return { addItem: (user: User) => dispatch({ type: actions.ADD_USER, payload: user }) } } const UsersContainer: React.FC<{users: User[], addItem: (user: User) => void}> = (props) => { return ( <> <h1>HOC connect</h1> <div> { users.map((user) => { return ( <User user={user} key={user.id} dispatchToStore={props.addItem} /> ) }) } </div> </> ) }; export default connect(mapStateToProps, mapDispatchToProps)(UsersContainer);
Code like this is bloated and repetitive. Typing mapStateToProps
and mapDispatchToProps
was annoying.
Below is the same code refactored to use Hooks:
import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { AppStore, User } from '../types'; import { actions } from '../actions/constants'; export const UsersContainer: React.FC = () => { const dispatch = useDispatch(); const users: User[] = useSelector((state: AppStore) => state.users); return ( <> <h1>Hooks</h1> { users.map((user) => { return ( <User user={user} key={user.id} dispatchToStore={dispatch} /> ) }) } </> ) };
The difference is night-and-day. Hooks provide a cleaner and simpler API. Hooks also eliminate the need to wrap everything in a component, which is another huge win.
The useEffect
Hook takes a function argument and a dependency array for the second argument.
import React, { useEffect, useState } from 'react'; export function Home() { const args = ['a']; const [value, setValue] = useState(['b']); useEffect(() => { setValue(['c']); }, [args]); console.log('value', value); }
The code above will cause the useEffect
Hook to spin infinitely because of this seemingly innocent assignment:
const args = ['a'];
On each new render, React will keep a copy of the dependency array from the previous render. React will compare the current dependency array with the previous one. Each element is compared using the Object.is
method to determine whether useEffect
should run again with the new values. Objects are compared by reference and not by value. The variable args
will be a new object on each re-render and have a different address in memory than the last.
Suddenly, variable assignments can have pitfalls. Unfortunately, there are many, many, many similar pitfalls surrounding the dependency array. Creating an arrow function inline that ends up in the dependency array will lead to the same fate.
The solution is, of course, to use more Hooks:
import React, { useEffect, useState, useRef } from 'react'; export function Home() { const [value, setValue] = useState(['b']); const {current:a} = useRef(['a']) useEffect(() => { setValue(['c']); }, [a]) }
It becomes confusing and awkward to wrap standard JavaScript code into a plethora of useRef
, useMemo
, or useCallback
Hooks. The eslint-plugin-react-hooks plugin does a reasonable job of keeping you on the straight and narrow, but bugs are not uncommon, and an ESLint plugin should be a supplement and not mandatory.
I recently published a react hook react-abortable-fetch and wrapping the runner
function in a combination of useRef
, useCallback
or useMemo
was not a great experience:
const runner = useCallback(() => { task.current = run(function* (scope) { counter.current += 1; send(start); try { for (const job of fetchClient.current.jobs) { const { fetch: { request, init, contentType, onQuerySuccess = parentOnQuerySuccess, onQueryError = parentOnQueryError, }, } = job; timeoutRef.current = timeout ? timeout : undefined; // etc.
The resulting dependency array is quite large and required to be kept up to date as the code changed, which was annoying.
}, [ send, timeout, onSuccess, parentOnQuerySuccess, parentOnQueryError, retryAttempts, fetchType, acc, retryDelay, onError, abortable, abortController, ]);
Finally, I had to be careful to memoize the return value of the Hook function using useMemo
and, of course, juggle another dependency array:
const result: QueryResult<R> = useMemo(() => { switch (machine.value as FetchStates) { case 'READY': return { state: 'READY', run: runner, reset: resetable, abort: aborter, data: undefined, error: undefined, counter: counter.current, }; case 'LOADING': return { state: 'LOADING', run: runner, reset: resetable, abort: aborter, data: undefined, error: undefined, counter: counter.current, }; case 'SUCCEEDED': return { state: 'SUCCEEDED', run: runner, reset: resetable, abort: aborter, data: machine.context.data, error: undefined, counter: counter.current, }; case 'ERROR': return { state: 'ERROR', error: machine.context.error, data: undefined, run: runner, reset: resetable, abort: aborter, counter: counter.current, }; } }, [machine.value, machine.context.data, machine.context.error, runner, resetable, aborter]);
Hooks need to run in the same order each time as is stated in the “Rules of Hooks“:
Don’t call Hooks inside loops, conditions, or nested functions.
It seems pretty strange that the React developers did not expect to see Hooks executed in event handlers.
The common practice is to return a function from a Hook that can be executed out of the Hooks order:
const { run, state } = useFetch(`/api/users/1`, { executeOnMount: false }); return ( <button disabled={state !== 'READY'} onClick={() => { run(); }} > DO IT </button> );
The simplification of the react-redux
code mentioned earlier is compelling and results in an excellent net code reduction. Hooks require less code than the previous incumbents, and this alone should make Hooks a no-brainer.
The pros of Hooks outweigh the cons, but it is not a landslide victory. Hooks are an elegant and clever idea, but they can be challenging to use in practice. Manually managing the dependency graph and memoizing in all the right places is probably the source of most of the problems, and this could do with a rethink. Generator functions might be a better fit here with their beautiful, unique ability to suspend and resume execution.
Closures are the home of gotchas and pitfalls. A stale closure can reference variables that are not up to date. A knowledge of closures is a barrier to entry when using Hooks, and you must come armed with that knowledge for debugging.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
One Reply to "React Hooks: The good, the bad, and the ugly"
I’ve found useState() to be nerve wracking and strange. It gives me more of what I don’t need, and the whole Hooks framework takes away things that I depend upon.
I depend on the ‘this’ object in class components. The object is created when the component is first used, and stays in existence until it vaporizes. I put a lot of intermediate and calculated variables in it. I also have references to important data structures in it. You could call these ‘state’, except really they never change, or rarely change, or, somehow, labeling them ‘state’ just doesn’t seem right.
Say I’ve got some state, in this.state. When these variables change, I need to go thru a lot of calculations to form data structures that render() can use to draw with. render() also happens when props change, but no recalculating needs to be done then. Where do I keep those intermediate data structures? Just toss them and recalculate every time there’s a render? I store them on ‘this’.
I have references to large data structures that are shared among half a dozen or more other components. Many components all have a reference to some of those data structures for easy access while the software is running. This is why I like a ‘this’ object – I can set those references and not have to rebuild my network of references every render, reaching thru this object to get the reference to the other object, from which I get another object I need. Do I throw all this info into a cave in the hopes that next time this instance of this component is called, it’ll return to me the exact same instances I threw into the cave?
All this goes way beyond the little toy examples I see for hooks. I have interdependencies between data in components that aren’t conveniently nested inside each other. You type in different numbers here, and the graphics over there changes. I have WebGL graphics that don’t redraw with the rest of the DOM, and cross-frame communication with other iframes.