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:
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
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
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
-
-
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 crazy 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>;
};