React Hooks — Gotchas

Andrew Grosner
5 min readMar 16, 2020

--

React Hooks are a way to use features that typically were only available to class components in functional components. It adds the ability to useState , useContext , and many other features not only without the need to create class components — but also to reuse common behavior in a functional way. You can also write your own custom hooks, introducing an emphasis on reusability and conciseness.

If you are not familiar with how to use hooks, please read this page.

Though with amazing power, if not careful, you could end up causing trouble.

Hooks provide several benefits to a functional component — less code, functional programming, reusability. If not careful, they do also introduce a few gotchas you must look out for.

Breakdown

In this article we will touch upon four topics:

Follow the Rules of Hooks

Hook Dependencies

useRef vs useState

State Objects with useState

Follow the Rules of Hooks

As the Rules Of Hooks states: Don’t call Hooks inside loops, conditions, or nested functions.

What this means is that they must be top-level. A couple of illegal examples:

const list = ['1', '2', '3']
list.forEach((item: string) => {
const [itemState, setItemState] = useState(item) // ERROR
})
if (condition) {
const [checked, setChecked] = useState(true)
// error
return <span>{checkedHook ? 'Checked' : 'Not Checked'}</span>
} else {
return <span>Hello</span>
}

Hook Dependencies

Hooks may depend on props. These are called hook dependencies. Whenever a dependency changes, the hook is recalculated automatically. How does this work?

Let’s first look at an example of hooks that have dependencies. Currently in React we have: useEffect , useCallback , `useMemo`, `useLayoutEffect`, `useImperativeHandle`.

I’m guaranteeing you’ve used useEffectto dispatch an action on first load that calls an API. For example, you want to load a set of users:

const MyComponent = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(loadUsers())
}, [dispatch])
return <span>hello</span>
}

Which is all fine and dandy. What if you need to, for some reason, notify the parent component when that effect dispatches? You might expose a prop:

const MyComponent = ({ startLoadingUsers }: Props) => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(loadUsers())
startLoadingUsers()
}, [dispatch, startLoadingUsers])
console.log('RENDER CHILD') // let's log our renders
return <span>hello</span>
}

Everything looks great. You expose it to the parent component as follows:

const ParentComponent = () => {
const [isLoading, setLoading] = useState(false)
const users = useSelector(selectUsers)
const loadUsers = () => {
setLoading(true)
}
console.log('RENDER PARENT')
return <MyComponent startLoadingUsers={loadUsers} />
}

What you might not expect is as follows:

The log goes infinitely and eventually will crash chrome when it runs out of memory.

What is happening? Let’s break it down:

  1. In our child component:
useEffect(() => {
dispatch(loadUsers())
startLoadingUsers()
}, [dispatch, startLoadingUsers])

We’re passing a function reference for startLoadingUsers . If that reference changes, we recompute the effect here.

2. Our parent component constructs the lambda on every render:

const loadUsers = () => {
setLoading(true)
}

3. Parent depends on a state piece that changes by the child component directly:

const users = useSelector(selectUsers)

This will re-render when the users state changes, causing #2 to be recomputed, passing it down to #1, causing a new network call to load users, which then in turn calls the selector here again infinitely in a loop.

To fix this, wrap loadUsers in useCallback in the Parent component

const loadUsers = useCallback(() => {
setLoading(true)
}, [setLoading])

This ensures only one instance gets passed down. This is not ideal as every parent component to our child component must remember to memoize the functions.

Good Practice 101: Always wrap functions declared inside your functional component with useCallback. This will ensure efficiency.

useRef vs useState

useRef is used to easily capture a value so we can perform an action on them as needed. For example, we can use them to focus an input element:

function TextInputWithFocusButton() {
const inputEl = useRef(null);
const onButtonClick = () => {
// `current` points to the mounted text input element
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}

This is great, however there is a gotcha. When the reference is set, useRef does not trigger a re-render in your component. Where this may be useful is when we want to grab the clientHeight of a specific component and adjust the UI based on it:

const bottomContainerRef = useRef<HTMLDivElement>(null)<HorizontalSpacer
background={COLORS.PaleGrey}
height={(bottomContainerRef && bottomContainerRef.clientHeight) || 0} />

This will always be set to 0 as when the first render pass happens, we do not have a ref quite yet.

To fix this, we need to change the useRef to a useState and then the UI will re-render as expected!

const [bottomContainerRef, setBottomContainerRef] = useState<HTMLDivElement | null>(null)

State Objects with useState

useState has opened doors to function components that were only available to class components. We typically had a state object as follows:

state: State = { 
userName: '',
password: '',
}

When we want to update the field input for userName we do:

const updateUserName = (value: string) => setState({ userName: value })

This version of setState diffs the object tree and re-renders the component.

When we useState:

const [{ userName, password }, setState] = useState({ 
userName: '',
password: '',
})
const updateUserName = useCallback((value: string) => {
setState({ userName: value })
}, [setState])

If state is currently:

userName: ‘Andrew’
password: ‘fluff’

Then we call updateUserName('Andy') , our state results in:

userName: 'Andy'
password: undefined

In fact, in Typescript this would not even compile. What happens here is that useState does a pure swap and not a diff. One potential solution is to pass the existing object before modifying it:

const updateUserName = useCallback((value: string) => {
setState({ ...state, userName: value })
}, [setState, state])

Conclusion

“With great power comes great responsibility”

Know your hooks and watch out for gotchas! They are a double-edged sword in that while they provide significant convenience, power, and responsibility rolled up into a great package, they also have internal complexities and gotchas you need to still be aware of.

Consult the online documentation on hooks. Ensure you are using them with performance in mind. When in doubt, console.log!

I hope you enjoyed this post.

--

--

Andrew Grosner

Senior Software Engineer @FuzzPro. Android, iOS, Web, React Native, Flutter, Ionic, anything