June 23, 2021
React Hooks: The good, the bad, and the ugly

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.

The good

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’;

const mapStateToProps = (state: AppStore) => ({
users: state.users
});

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 bad

The dependency array

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.

The ugly

I recently published a react Hook, react-abortable-fetch, and wrapping everything in a combination of useRef, useCallback, or useMemo was not a great experience:

const [machine, send] = useMachine(createQueryMachine({ initialState }));
const abortController = useRef(new AbortController());
const fetchClient = useRef(createFetchClient<R, T>(builderOrRequestInfos, abortController.current));
const counter = useRef(0);
const task = useRef<Task>();
const retries = useRef(0);
const timeoutRef = useRef<number | undefined>(timeout ?? undefined);
const accumulated = useRef(initialState);

const acc = accumulator ?? getDefaultAccumulator(initialState);

const abortable = useCallback(
(e: Error) => {
onAbort(e);
send(abort);
},
[onAbort, send],
);

// 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]);

Execution order

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 verdict

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.

The post React Hooks: The good, the bad, and the ugly appeared first on LogRocket Blog.

Leave a Reply

Your email address will not be published. Required fields are marked *

Send