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.
- You usually need to either "freeze" the array at export:
- When writing custom React hooks in TypeScript, it is important to note that the syntax for consuming React hooks (
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 useEffect
s.
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:
- A) Not clearly documented, and I would argue not known by most users
- B) Is considered an implementation detail and not intended to be relied upon
- C) Was overhauled in React 18
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 ofinternalHandler
, 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 usinguseCallback()
; we are back to square one, whereuseEffect
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
- 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:
- 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>; };
- 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
- 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 ofuseCallback
means that every time the component re-renders, it changes- Since
twoSecCallback
is a dependency ofinternalHandler
, when the callback variable changes, theuseCallback
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 ofuseEffect
, 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
- Error: "Rendered more hooks than during the previous render"
- Check for accidental use of
onClick={myFunc()}
instead ofonClick={myFunc}
- Check for conditional use of hooks (not allowed)
- Check for accidental use of