Joshua's Docs - React Hooks - Cheat Sheet for Built-In Hooks and Custom
Light

What are React Hooks?

React hooks partially came about because of, and an alternative to, the complexity and boilerplate involved with class based components (as opposed to functions) and/or HOCs (higher order components). They are not required to be used, but many new devs prefer them for readability and isolation.

At their core, the key value proposition of React hooks is that they allow you to hook more directly into the React lifecycle and core APIs, such as state, props, etc.

Built-In React Hooks

Hook What
useState Get and set state
useEffect Wrap a function that contains possibly effectful code, which will be called after render.

You can conditionally trigger it by passing a dependency array as the second argument.

You can use it as a cleanup method (before component unmounts), by returning a cleanup function.
useContext Returns the current value for a given context object (object from React.createContext). Value is from nearest Provider.

A context value change will always trigger a re-render.

Using this with a context provider is a common alternative to prop-drilling, and/or large global state management systems.
useReducer An alternative to useState that basically takes a function to mutate state, rather than the state update directly.
useCallback Meant for optimization, you pass a callback and the dependencies that the callback uses to produce a result, and you get back a memoized (i.e. cached) callback function that only changes if the dependencies change.
useMemo Close to useCallback, but returns a memoized value, rather than a callback, that is only recomputed if the dependencies change.

Good use case is for computed values derived from state.
useRef Returns a special mutable reference object, which can be updated, but persists across re-renders and does not trigger re-renders on change.

Value can be accessed with .current.
useImperativeHandle Lets you modify the reference that is passed to parent components (for example, to add a method onto ref.current). If used, should be combined with forwardRef, but also not recommended, period.
useLayoutEffect Essentially the same as useEffect, but fires after all DOM updates have occurred (e.g. at componentDidMount, or componentDidUpdate)
useDebugValue For custom hooks, you can use this to display a special label for the hook inside the React DevTools.

TypeScript Concerns

Be aware that TypeScript can be particular about how you use hooks and annotate types. For example, for useState, you should pass the type through a generic slot if it is anything other than a primitive and you are initializing to an empty value and/or TS cannot infer the full type:

interface Book {
	title: string;
	copyrightYear: number;
}
const [books, setBooks] = useState<Book[]>([]);
// Types for `books` and `setBooks` can now be determined

const [counter, setCounter] = useState(0);
// This is fine, because `number` can be inferred

📄 The React TypeScript Cheatsheet has a section on Hooks

useContext - A Closer Look

The useContext hook is a powerful hook, and one that moves the needle towards not needing a third-party state management library when working with cross-component state. Many developers advocate for dropping things like Redux, and using patterns involving useContext as a replacement.

Updating the parent value

Make sure you are using state (via useState, and not just a top level const object or something like that). React docs are a little confusing here, since most of the context docs still seem to be written for a class based provider approach, and not hooks...

useReducer - A Closer Look

useReducer - Dispatch Fires Twice

A common issue with useReducer is that you might see React calling your reducer twice, from a single dispatch call. This is intentional behavior from React, and is not a bug on their end. If your reducer action is not pure / idempotent (meaning that the output of the action does not stay the same if called twice), this will likely cause some issues (this applies to redux too).

The likely culprit, as mentioned, is that your reducer action is not pure, but what leads to this? Here are some things to check:

  • Accidentally returning state, plus something extra. This is really easy to miss with TypeScript, because the type-checker might tell you everything is alright, giving you false confidence, when you have done something like:
    return {
    	...state
    	keyNotInState: value
    }
  • Mutating state objects (usually pass by reference related), instead of copying, and then returning mutated copy
    // Don't do this:
    state.count = 2;
    return state;
    
    // Do this
    return {
    	...state,
    	count: 2
    }

📄 Additional related resource: TrackJS - Common Redux Bugs

Building Your Own Hooks

You can easily write, share, and reuse custom hooks across many different components. In fact, this is one of the main selling points of hooks:

Hooks allow you to reuse stateful logic without changing your component hierarchy.

To create and re-use a hook, simply define it as an exported function, and then import where you need to use it. See the docs for details.

Custom React Hook - Sample Syntax

Remember to prefix with use.

Here is a general template:

export function useMyHook() {
	// Instantiate state vars
	const [myHookState, setMyHookState] = useState(null);

	// Setup code to update the hook state and handle removal
	useEffect(() => {
		// Register things (e.g. listeners, subscriptions, etc) to call setMyHookState
		return () => {
			// Un-register things that will call setMyHookState
		}
	});

	// Don't forget to return the state / val
	return myHookState;
}

Custom React Hooks - Examples

React Hook - Window Dimensions
export const useWindowSize = () => {
	const [size, setSize] = useState([0, 0]);

	useLayoutEffect(() => {
		// Need to hold function reference so we can use it for cleanup
		const updateSize = () => {
			setSize([window.innerWidth, window.innerHeight]);
		};
		window.addEventListener('resize', updateSize);
		updateSize();

		return () => window.removeEventListener('resize', updateSize);
	}, []);

	return size;
};
React Hook - Document Title
export const useTitle = (initialTitle) => {
	const [title, setTitle] = useState(initialTitle || document.title);

	useEffect(() => {
		document.title = title;
	}, [title]);

	return [title, setTitle];
}

Custom React Hooks - Important Things to Note

  • React Hooks + TypeScript
    • When writing custom React hooks in TypeScript, it is important to note that the syntax for consuming React hooks (const [val, setVal] = useMyHook()) and exporting them (return [val, setVal];) can be problematic in TypeScript
      • You usually need to either "freeze" the array at export:
        • return [val, setVal] as const;
      • Or, strictly define the return type of your hook as a defined tuple type (an Array in TS with known fixed types & length)
      • See this blog post for details.

Chaining Hooks

You can "chain" hooks, where one hook update triggers another, by pulling them in as dependencies (second argument to useEffect).

For example, if I've already created a useWindowSize hook that updates when the window is resized, I could reuse that hook as the trigger to re-calculate the size of an element on the page, like so:

export const useElemSize = (elementRef) => {
	const [size, setSize] = useState({ width: 0, height: 0 });

	// Use windowSize hook as trigger to re-evaluate
	const windowSize = useWindowSize();

	useEffect(() => {
		try {
			const elem = elementRef.current;
			/** @type {DOMRect} */
			const domRect = elem.getBoundingClientRect();
			setSize({
				width: domRect.width,
				height: domRect.height
			});
		} catch (e) {
			setSize({ width: 0, height: 0 });
		}
	}, [windowSize, elementRef]);

	return size;
};

Problem Areas

useState Asynchronous Nature

Both the useState hook for function components and the parallel setState method for class-based components are asynchronous in nature, but currently the React docs do a better job of outlining this for class components than they do for hooks (the useState docs currently don't even use the word asynchronous once).

Additionally, while the class-based setState accepts a callback for receiving the "settled" state, there is no callback option for useState, which can lead to (IMHO) more footguns and complex layers of useEffects.

Another footgun here is that React tends to abstract how these state updates are queued and batched together. For example, in React 17 and below, state updates were only batched inside of native event handlers, such as onClick, but this was:

Relevant StackOverflow

useEffect Dependencies

One major problem area with React hooks that I've encountered is the dependency array of useEffect. Even with things like eslint-plugin-react-hooks and the bundled react-hooks/exhausitve-deps rule, it is a little too easy to run into confusing cases.

useEffect Dependencies - Outside Callbacks

One of the very first mistakes I made with React hooks was something like this:

// This is bad code

const MyComponent = () => {

	const internalHandler = () => {
		// bunch of code
		// something that touches state or props and *causes re-render*:
	}

	useEffect(() => {
		// register handler once
		window.setInterval(internalHandler, 1000);
	}, [internalHandler])
	
	return (
		// Assume some jsx
	)
}

The big issue with the above is that I've accidentally created an infinite loop.

  • Whenever MyComponent is called (rendered), a new internalHandler is defined
  • Because internalHandler is a dependency for useEffect, it getting created on render is triggering it and causing the handler to get registered again
  • internalHandler does something to state / props and triggers a re-render, which starts the loop all over again

The ESLint hooks rule will actually catch this, and warn with something like:

⚠ The 'internalHandler' function makes the dependencies of useEffect Hook (at line ___) change on every render. Move it inside the useEffect callback. Alternatively, wrap the 'internalHandler' definition into its own useCallback() Hook

That looks like a helpful error! Using either of the solutions it mentions should work. But... what if our case is complex, and wrapping it in useCallback still updates a part of state that it itself needs? Here is an example:

export const MyComponent = () => {
	const [counter, setCounter] = useState(0);

	const internalHandler = useCallback(() => {
		// something that touches state and *causes re-render*
		setCounter(counter + 1);
	}, [counter]);

	useEffect(() => {
		// register handler once
		const timerId = window.setInterval(internalHandler, 1000);
		console.log(`Registered handler #${timerId}`);
	}, [internalHandler]);

	return <div>It has been {counter} seconds!</div>;
};

On the surface, this might look OK; we are using useCallback to avoid recreating the callback on re-renders, and there are no eslint errors about exhaustive dependencies... but... we just did it again! We created another circular infinite loop!

  • counter is a state value, so changing it will cause a re-render
  • Since counter is a dependency of internalHandler, changing it also means that React thinks the handler is stale, and will create a new instance
  • React creating a new instance of internalHandler is basically just as bad as if we had declared it without using useCallback(); we are back to square one, where useEffect runs on every render and keeps registering new (separate) handlers

Solution? Here are some approaches:

  • Set state variables in callbacks, but don't use the same ones you are setting!
    • If the new state depends on the old one, like our counter example, an easy workaround is to use the alternative syntax for the state setter:
      // Rewrite this:
      const internalHandler = useCallback(() => {
      	setCounter(counter + 1);
      }, [counter]);
      
      // To this:
      const internalHandler = useCallback(() => {
      	setCounter(counter => counter + 1);
      }, []);
      // ^ a state variable is no longer a dependency
  • Use ref instead of state, where appropriate
    • Warning: a change to a ref value will not trigger a re-render by itself. So although this would fix the circular issue in our example, the DOM would continue to show It has been 0 seconds no matter how much time has actually passed. Here is how we could rewrite our example to use a ref:
      export const MyComponent = () => {
      	const [elapsedSec, setElapsedSec] = useState(0);
      	const startTime = useRef(new Date().getTime());
      
      	const internalHandler = useCallback(() => {
      		// something that touches state and *causes re-render*
      		const now = new Date().getTime();
      		setElapsedSec(Math.floor((now - startTime.current) / 1000));
      	}, []);
      
      	useEffect(() => {
      		// register handler once
      		const id = window.setInterval(internalHandler, 1000);
      		console.log(`Registered handler ${id}`);
      	}, [internalHandler]);
      
      	return <div>It has been {elapsedSec} seconds!</div>;
      };
  • Avoid using state entirely, for something that is simply a computed property
  • When possible, move callbacks directly inside useEffect, so they don't have to be dependencies and can live inside the right closure

useCallback Infinite Loop

It is also very easy to accidentally repeat the exact same circular issue with the dependency array of useCallback as outlined above with `useEffect, when trying to use a variable that is scoped to the outer closure:

export const MyComponent = props => {
	const [elapsedSec, setElapsedSec] = useState(0);
	const startTime = useRef(new Date().getTime());

	// In case callback is not defined in props
	const twoSecCallback =
		props.twoSecCallback ||
		(() => {
			console.log(`2 seconds are up!`);
		});

	const internalHandler = useCallback(() => {
		// something that touches state and *causes re-render*
		const now = new Date().getTime();
		const elapsedSec = Math.floor((now - startTime.current) / 1000);
		if (elapsedSec === 2) {
			twoSecCallback();
		}
		setElapsedSec(Math.floor((now - startTime.current) / 1000));
	}, [twoSecCallback]);

	useEffect(() => {
		// register handler once
		const id = window.setInterval(internalHandler, 1000);
		console.log(`Registered handler ${id}`);
	}, [internalHandler]);

	return <div>It has been {elapsedSec} seconds!</div>;
};

There are no ESlint hook warnings, but the above will indeed cause issues due to what is essentially the same problem as outlined in the above section on callbacks outside useEffect:

  • twoSecCallback being declared outside the closure of useCallback means that every time the component re-renders, it changes
  • Since twoSecCallback is a dependency of internalHandler, when the callback variable changes, the useCallback wrapper (internalHandler) is deemed to be stale, and React will create a new version
  • React creating a new version of the useCallback closure (internalHandler) means that, since it is a dependency of useEffect, the effect function will run again
  • The call stack on subsequent renders ends up looking like render() -> useEffect runs due to (CHANGE) in internalHandler, registers new internalHandler -> twoSecCallback {CHANGE} -> internalHandler {CHANGE} -> (NEXT RENDER) -> useEffect runs due to... and so on. Until you have hundreds of duplicate timers, handlers, and a crashed browser tab 🔥💥😬

The solution here is similar to the solutions with useEffect. The easiest solution in this case is simply to move the callback variable declaration within the closure of useCallback:

export const MyComponent = props => {
	const [elapsedSec, setElapsedSec] = useState(0);
	const startTime = useRef(new Date().getTime());

	const internalHandler = useCallback(() => {
		// In case callback is not defined in props
		const twoSecCallback =
			props.twoSecCallback ||
			(() => {
				console.log(`2 seconds are up!`);
			});
		// something that touches state and *causes re-render*
		const now = new Date().getTime();
		const elapsedSec = Math.floor((now - startTime.current) / 1000);
		if (elapsedSec === 2) {
			twoSecCallback();
		}
		setElapsedSec(Math.floor((now - startTime.current) / 1000));
	}, []);

	useEffect(() => {
		// register handler once
		const id = window.setInterval(internalHandler, 1000);
		console.log(`Registered handler ${id}`);
	}, [internalHandler]);

	return <div>It has been {elapsedSec} seconds!</div>;
};

Miscellaneous Troubleshooting

Markdown Source Last Updated:
Sat Aug 06 2022 12:12:45 GMT+0000 (Coordinated Universal Time)
Markdown Source Created:
Mon Aug 31 2020 09:49:09 GMT+0000 (Coordinated Universal Time)
© 2024 Joshua Tzucker, Built with Gatsby
Feedback