Some hooks add a behaviour to any component, like closing on a click outside, without owning any tags. Learn the shapes they take, prop getters, and how to fix handlers that see old values.
In Part 16 you moved logic out of a component and into your own hook. Two components could then use the same logic, and each one kept its own state.
This part is about one kind of custom hook. It adds a behaviour to a component. A behaviour is something the page does when the user acts. Close a menu when the user clicks outside it. Close a box when the user presses Escape. Wait until the user stops typing, then search.
These hooks have no tags of their own. The tags a component returns are called its markup. The component keeps its markup, and the hook only adds the behaviour. So one hook can work with a menu, a box, a card, or anything else.
We’ll build several of these hooks. On the way we’ll meet a bug they often have: a handler that sees old values. React 19.2 added a hook for exactly that, useEffectEvent, which Part 11 named. The examples use Effects from Part 11, cleanup from Part 12 and refs from Part 14.
Try this first
Read this code. Don’t press Run yet.
import { useEffect, useState } from 'react'
function useKeyPress(key: string, onPress: () => void) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === key) onPress()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [key, onPress])
}
function Menu() {
const [open, setOpen] = useState(false)
useKeyPress('Escape', () => setOpen(false))
return (
<div>
<button onClick={() => setOpen(true)}>Open menu</button>
{open && (
<ul>
<li>Profile</li>
<li>Settings</li>
</ul>
)}
</div>
)
}
function HelpBox() {
const [open, setOpen] = useState(false)
useKeyPress('Escape', () => setOpen(false))
return (
<section>
<button onClick={() => setOpen(true)}>Show help</button>
{open && <p>Press Escape to close this help.</p>}
</section>
)
}
export default function App() {
return (
<div>
<Menu />
<HelpBox />
</div>
)
}
useKeyPress listens for one key on the whole page. When that key is pressed, it calls onPress. Menu and HelpBox both use it.
Make a guess. You open the menu, then the help. Then you press Escape once. Which one closes?
Press Run. Click “Open menu” and “Show help”. Then press the Escape key. The key goes to the result box, because you just clicked inside it. Phones usually have no Escape key, so try the key examples in this part on a computer.
Both close. Each component called useKeyPress, so each one added its own listener to document. One key press reached both listeners.
Look at what useKeyPress returns: nothing. It has no JSX. Menu draws a list, and HelpBox draws a paragraph. The hook doesn’t depend on what they draw. It only adds one behaviour: “close when Escape is pressed”.
What a behavioural hook is
A behavioural hook is a custom hook that adds a behaviour to a component’s own tags. It doesn’t return any tags itself. So the behaviour can be used again, and the markup stays with each component.
A hook like this needs some way to connect to the component’s tags. Here are four shapes these hooks often take. We’ll build one of each in this part.
| The hook | Example | What the component does |
|---|---|---|
| takes a ref from you | useClickOutside(ref, onOutside) |
puts the ref on one of its own tags |
| gives back props | getButtonProps() |
spreads the props onto its own tag |
| gives back a value | useDebouncedValue(text, 500) |
shows the value, or uses it |
| gives back nothing | useKeyPress('Escape', close) |
nothing more |
In every shape, the component decides which tags go on the page. The hook never does.
An everyday example
Think of a smoke alarm. You can put the same alarm in a kitchen or in a bedroom. It doesn’t come with a room. You choose a place on the wall, and it adds one behaviour: it rings when there is smoke.
The room is the markup. The alarm is the hook.
The exact version
The picture breaks in two places.
First, a smoke alarm only hears its own room. A hook that listens on document hears the whole page. That’s why one Escape closed both the menu and the help.
Second, there is not one alarm that two rooms share. Each component that calls the hook gets its own Effect and its own listener. Part 16 said the same about state: hooks share logic, not state.
useClickOutside: closing a menu
A dropdown is a menu that opens under a button. It should close when you click somewhere else on the page. Here is that behaviour as a hook. The hook takes a ref and a function.
import { useEffect, useRef, useState, type RefObject } from 'react'
function useClickOutside(ref: RefObject<HTMLElement | null>, onOutside: () => void) {
useEffect(() => {
function handlePointerDown(e: PointerEvent) {
const box = ref.current
if (box && !box.contains(e.target as Node)) onOutside()
}
document.addEventListener('pointerdown', handlePointerDown)
return () => document.removeEventListener('pointerdown', handlePointerDown)
}, [ref, onOutside])
}
function Dropdown() {
const [open, setOpen] = useState(false)
const boxRef = useRef<HTMLDivElement>(null)
useClickOutside(boxRef, () => setOpen(false))
return (
<div ref={boxRef} style={{ display: 'inline-block' }}>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && (
<ul>
<li>Profile</li>
<li>Settings</li>
</ul>
)}
</div>
)
}
export default function App() {
return (
<div>
<Dropdown />
<p>Click anywhere down here to close the menu.</p>
</div>
)
}
Run it. Press “Menu” to open the list. Then click the sentence below it. The list closes. Open it again and click “Profile”. The list stays open, because “Profile” is inside the menu.
Here is what each piece does.
Dropdownmakes the ref and puts it on its own<div>. The hook never sees JSX. It only gets the ref, and readsref.currentlater.display: 'inline-block'makes the<div>only as wide as what is inside it. Without it, a<div>is as wide as the page. Then a click in the empty space beside the button would count as inside.pointerdownis a browser event. It happens the moment a mouse button goes down, or a finger or pen touches the screen. So it works on phones too.e.targetis the tag that was pressed.as Nodetells TypeScript that it is a node, which means a piece of the page.box.contains(...)asks: “is this node inside the box, or the box itself?” If not, the press was outside. Then the hook callsonOutside.
We tested this hook with Strict Mode on. Strict Mode ran setup, cleanup and setup again, as Part 12 showed. Then exactly 1 pointerdown listener was left on document. A press outside closed the menu. A press on “Profile” did not. After the component was removed, 0 listeners were left.
One hook, two kinds of markup
The hook doesn’t care what the markup looks like. Here it works with a dropdown and with a modal. A modal is a box that sits on top of the page, usually over a dark layer. The user must deal with it before going back to the page.
One behaviour hook, two different pieces of markup. Press play, or step through it.
Here are the same steps in words.
useClickOutsidehas no tags. It listens for presses on the whole page.- The menu puts the ref on its own
<div>, around the button and the list. - A press lands outside that
<div>. The hook callsonOutside, and the menu closes. - The modal puts the ref on its white box, not on the dark layer around it.
- A press lands on the dark layer. That is outside the box, so the modal closes.
The same hook works with two very different pieces of markup. Try both.
import { useEffect, useRef, useState, type RefObject } from 'react'
function useClickOutside(ref: RefObject<HTMLElement | null>, onOutside: () => void) {
useEffect(() => {
function handlePointerDown(e: PointerEvent) {
const box = ref.current
if (box && !box.contains(e.target as Node)) onOutside()
}
document.addEventListener('pointerdown', handlePointerDown)
return () => document.removeEventListener('pointerdown', handlePointerDown)
}, [ref, onOutside])
}
function Dropdown() {
const [open, setOpen] = useState(false)
const boxRef = useRef<HTMLDivElement>(null)
useClickOutside(boxRef, () => setOpen(false))
return (
<div ref={boxRef} style={{ display: 'inline-block' }}>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && <p>Profile, Settings</p>}
</div>
)
}
function Modal({ onClose }: { onClose: () => void }) {
const boxRef = useRef<HTMLDivElement>(null)
useClickOutside(boxRef, onClose)
return (
<div style={{ background: 'rgba(0, 0, 0, 0.5)', padding: 24 }}>
<div ref={boxRef} style={{ background: 'white', color: 'black', padding: 16 }}>
<p>Save your changes?</p>
<button onClick={onClose}>Close</button>
</div>
</div>
)
}
export default function App() {
const [showModal, setShowModal] = useState(false)
return (
<div>
<div>
<button onClick={() => setShowModal(true)}>Open the modal</button>
</div>
<Dropdown />
{showModal && <Modal onClose={() => setShowModal(false)} />}
</div>
)
}
A real modal covers the whole page. Ours must fit inside the result box. So it is a plain <div>: a dark layer with the white box inside.
Run it. Open the modal, then click the dark area around the white box. The modal closes. Clicking inside the white box does nothing, except on “Close”. We checked both in our test.
The “Open the modal” button sits above the menu on purpose. Below the menu, it would move up when a press closed the list. Then the click would end somewhere else, and the modal would not open. That is a real problem with closing things on pointerdown: the page can move under the user’s finger.
Hooks that give back props
Some behaviours need more than a listener on document. They need props on the component’s own tag: an onClick, or an aria- attribute. The hook can’t put them there itself, because it doesn’t own the tag. So it hands the props back, and the component spreads them on.
You met spread props in Part 3: {...buttonProps} copies each key of the object onto the tag as one prop.
Here is a hook for a disclosure: a button that shows and hides a panel.
import { useState } from 'react'
function useDisclosure() {
const [open, setOpen] = useState(false)
const buttonProps = {
'aria-expanded': open,
onClick: () => setOpen(!open),
}
return { open, buttonProps }
}
export default function App() {
const { open, buttonProps } = useDisclosure()
return (
<div>
<button {...buttonProps}>Details</button>
{open && <p>Ships in 2 days. Free returns.</p>}
</div>
)
}
Run it and press “Details”. The panel appears.
aria-expanded tells a screen reader whether the panel is open. A screen reader is a program that reads the page out loud, for people who can’t see it well. Our test page had aria-expanded="false" at first, and "true" after the click.
When your own onClick meets the hook’s
Now say the component also wants to count clicks. It adds its own onClick:
import { useState } from 'react'
function useDisclosure() {
const [open, setOpen] = useState(false)
const buttonProps = {
'aria-expanded': open,
onClick: () => setOpen(!open),
}
return { open, buttonProps }
}
export default function App() {
const { open, buttonProps } = useDisclosure()
const [clicks, setClicks] = useState(0)
return (
<div>
<button {...buttonProps} onClick={() => setClicks(clicks + 1)}>Details</button>
<p>Clicks: {clicks}</p>
{open && <p>Ships in 2 days. Free returns.</p>}
</div>
)
}
Run it and press “Details”. The count goes up, but the panel never opens.
The tag got two onClick props. As Part 3 showed, the last one wins. Ours came last, so it replaced the hook’s. We also tried it the other way around, with the spread after our own onClick. Then the panel opened, but the count stayed at 0. In an editor, TypeScript marks that order with 'onClick' is specified more than once, so this usage will be overwritten, as in Part 3. The playground doesn’t check types, so it runs, and the hook’s onClick wins.
Prop getters: a function that combines props
The fix is to give the hook your own props, and let it combine them. The hook returns a function instead of an object. You call it with your props, and it returns one set of props for the tag.
import { useState, type ComponentProps, type MouseEvent } from 'react'
function useDisclosure() {
const [open, setOpen] = useState(false)
function getButtonProps(own: ComponentProps<'button'> = {}) {
return {
...own,
'aria-expanded': open,
onClick: (e: MouseEvent<HTMLButtonElement>) => {
setOpen(!open)
if (own.onClick) own.onClick(e)
},
}
}
return { open, getButtonProps }
}
export default function App() {
const { open, getButtonProps } = useDisclosure()
const [clicks, setClicks] = useState(0)
return (
<div>
<button {...getButtonProps({ title: 'Shipping', onClick: () => setClicks(clicks + 1) })}>
Details
</button>
<p>Clicks: {clicks}</p>
{open && <p>Ships in 2 days. Free returns.</p>}
</div>
)
}
Run it. Now one click does both jobs: the panel opens and the count goes up.
A function like getButtonProps is called a prop getter. Here is what it does with your props.
ComponentProps<'button'>is the type for every prop a<button>can take.= {}means “an empty object if you pass nothing”....owncopies all your props first. Sotitle, and anything else you pass, reaches the button as it is.- Then come the hook’s own props.
aria-expandedreplaces yours if you passed one.onClickdoesn’t replace yours: it calls the hook’s code, then yours.
So only onClick is combined. A hook that needs other handlers, like onKeyDown, must combine those the same way.
Kent C. Dodds wrote a library called Downshift. He says it was the first library he knows of to use this pattern. He describes a prop getter as “a function which will return props when called”. You then put those props on the right tag. His version calls both click handlers, the way ours does.
When a hook keeps an old handler
The hooks so far take a function, like onPress or onOutside. That function is often written in place, right where the hook is called: () => setOpen(false). And that causes a hidden cost.
A new listener after every render
Here is useKeyPress from “Try this first”, with one log added to the setup. This app counts with a button, and saves the count when you press the S key. The hook checks for a small s, so keep Caps Lock off.
import { useEffect, useState } from 'react'
function useKeyPress(key: string, onPress: () => void) {
useEffect(() => {
console.log('add listener')
function handleKeyDown(e: KeyboardEvent) {
if (e.key === key) onPress()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [key, onPress])
}
export default function App() {
const [count, setCount] = useState(0)
const [saved, setSaved] = useState('nothing yet')
useKeyPress('s', () => setSaved('count was ' + count))
return (
<div>
<button onClick={() => setCount(count + 1)}>Add one</button>
<p>Count: {count}</p>
<p>Saved: {saved}</p>
</div>
)
}
add listener
add listener
add listener
add listener
add listener
Press Run, then click “Add one” twice. Then press S. The page says “Saved: count was 2”, which is right.
Now read the Console. The first two lines come from Run. That is Strict Mode’s test from Part 12: setup, cleanup, setup. The cleanup prints nothing, so you see two setups. After that, every render ran the cleanup and the setup again. The old listener was removed, and a new one was added. Two clicks gave two renders. Pressing S gave a third, because it set saved.
Why? () => setSaved('count was ' + count) makes a new function on every render. onPress is in the array, so the Effect sees a new value each time, and runs again. It still works. Now think of a hook that adds a listener, a timer or a connection. It sets that up again after every render.
Leaving the handler out of the array
You might try to stop this by taking onPress out of the array. Change only that line:
import { useEffect, useState } from 'react'
function useKeyPress(key: string, onPress: () => void) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === key) onPress()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [key])
}
export default function App() {
const [count, setCount] = useState(0)
const [saved, setSaved] = useState('nothing yet')
useKeyPress('s', () => setSaved('count was ' + count))
return (
<div>
<button onClick={() => setCount(count + 1)}>Add one</button>
<p>Count: {count}</p>
<p>Saved: {saved}</p>
</div>
)
}
Run it. Click “Add one” three times, then press S. The page says “Count: 3” but “Saved: count was 0”.
The listener was added once, after the first render. It holds the onPress from that render, and that function saw count as 0. Later renders made new functions, but the listener never got them. Each render has its own snapshot of state, as Part 4 showed. This listener is stuck on the first one.
A handler that holds old values like this is called a stale handler. The linter rule from Part 11 catches this one. ESLint with eslint-plugin-react-hooks 7.1.1 printed: React Hook useEffect has a missing dependency: 'onPress'. The rest of its message suggests useCallback, which Part 21 covers. This part uses a different fix.
So both choices are bad. With onPress in the array, the listener is set up again after every render. Without it, the handler is stale. We want a listener that is added once, but always calls the newest handler.
Fix 1: keep the newest handler in a ref
A ref keeps a value between renders, and changing it doesn’t cause a render. That’s Part 14. So keep the newest onPress in a ref. The listener reads the ref each time a key is pressed.
import { useEffect, useRef, useState } from 'react'
function useKeyPress(key: string, onPress: () => void) {
const onPressRef = useRef(onPress)
useEffect(() => {
onPressRef.current = onPress
})
useEffect(() => {
console.log('add listener')
function handleKeyDown(e: KeyboardEvent) {
if (e.key === key) onPressRef.current()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [key])
}
export default function App() {
const [count, setCount] = useState(0)
const [saved, setSaved] = useState('nothing yet')
useKeyPress('s', () => setSaved('count was ' + count))
return (
<div>
<button onClick={() => setCount(count + 1)}>Add one</button>
<p>Count: {count}</p>
<p>Saved: {saved}</p>
</div>
)
}
add listener
add listener
Run it, click three times and press S. It says “count was 3”. And “add listener” appears only twice, both on Run. One is the setup, and one is Strict Mode’s test. The clicks add nothing.
The first Effect has no array, so it runs after every render. It copies the newest onPress into the ref. Why an Effect, and not a plain line in the body? Part 14’s rule: don’t write ref.current while rendering.
This is often called the latest ref pattern. You’ll see it in many libraries and older code.
There is a short gap. An Effect runs after the page is drawn, so for a moment the ref still holds the old handler. A key press in that moment calls the old one. Some libraries update the ref in useLayoutEffect instead, which runs before the page is drawn. Part 11 compared the two. The usehooks-ts library’s useEventCallback does this.
Fix 2: useEffectEvent
React 19.2 added a hook for exactly this job, useEffectEvent. Part 11 named it. The playground runs React 19.3, so you can use it here.
import { useEffect, useEffectEvent, useState } from 'react'
function useKeyPress(key: string, onPress: () => void) {
const onKey = useEffectEvent(onPress)
useEffect(() => {
console.log('add listener')
function handleKeyDown(e: KeyboardEvent) {
if (e.key === key) onKey()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [key])
}
export default function App() {
const [count, setCount] = useState(0)
const [saved, setSaved] = useState('nothing yet')
useKeyPress('s', () => setSaved('count was ' + count))
return (
<div>
<button onClick={() => setCount(count + 1)}>Add one</button>
<p>Count: {count}</p>
<p>Saved: {saved}</p>
</div>
)
}
add listener
add listener
It gives the same result as the ref: “count was 3”, and the listener is added only on Run.
useEffectEvent(onPress) returns a new function, onKey. React calls this an Effect Event. When you call it, it runs your function with the newest values. React’s docs say the function “always accesses the latest committed values from render”. Committed means the values from the last render that React put on the page.
React’s docs give a few rules for it:
- Call an Effect Event only from inside an Effect, like
onKey()in the listener above. Don’t call it while rendering, and don’t pass it to other components or hooks. - Don’t put it in the dependency array. The linter knows this. It printed nothing for the hook above.
- Don’t use it to hide a value the Effect should react to.
keyis still in the array. If the key changes, the listener should change too.
React’s docs show this same shape: a custom hook that wraps the function it was given in useEffectEvent. Use this fix in new code. Use the latest ref when you work on code that runs on React older than 19.2.
A hook that gives back a value: debounce
Some behaviours are about time. Say a search box asks a server for results. If it searches on every key, typing “react” sends five requests. It is better to wait until the user stops typing.
Waiting like this is called debouncing. A debounced value changes only after the original value has stayed the same for a while.
import { useEffect, useState } from 'react'
function useDebouncedValue(value: string, delay: number) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}
export default function App() {
const [text, setText] = useState('')
const query = useDebouncedValue(text, 500)
useEffect(() => {
if (query !== '') console.log('search for ' + query)
}, [query])
return (
<div>
<input aria-label="Search" value={text} onChange={e => setText(e.target.value)} />
<p>You typed: {text}</p>
<p>Searching for: {query}</p>
</div>
)
}
search for rea
Run it, and type rea quickly into the box. “You typed” changes on every key. “Searching for” waits, then changes once.
The cleanup does the work here. Each key changes value, so React runs the old cleanup first. That stops the old timer. Then a new timer for 500 milliseconds starts. Only the last timer lives long enough to set the value.
We typed rea one key at a time, 150 milliseconds apart. “Searching for” changed once, about 500 milliseconds after the last key. The log printed search for rea once. Then we typed with 700 milliseconds between keys. That is longer than the delay, so each pause finished a timer. It printed three searches: for r, re and rea.
This hook doesn’t touch any tags. It gives back a value, and the component decides how to use it.
Part 14 kept the previous value in a ref. It showed why reading that ref while rendering can show the wrong value.
Hooks that need a real browser
Two more behaviour hooks use browser tools. The playground can run both, because it is a real browser. Our test page runs in jsdom, a pretend browser for Node.js. We checked: jsdom has neither matchMedia nor IntersectionObserver. So our jsdom test can’t run these two. They are checked in a real browser instead.
useMediaQuery: does the screen match?
CSS can ask questions about the screen, like “is it at least 600 pixels wide?”. Pixels are the tiny dots that make up the screen. Such a question is called a media query. window.matchMedia(query) asks it from JavaScript. Its answer has a matches field, and a 'change' event when the answer changes.
import { useEffect, useState } from 'react'
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
useEffect(() => {
const list = window.matchMedia(query)
function handleChange() {
setMatches(list.matches)
}
list.addEventListener('change', handleChange)
return () => list.removeEventListener('change', handleChange)
}, [query])
return matches
}
export default function App() {
const wide = useMediaQuery('(min-width: 600px)')
return <p>This box is {wide ? 'wide' : 'narrow'}.</p>
}
Run it. Then make your browser window narrower and wider. The text changes when the result box crosses 600 pixels. The playground runs the code in a frame of its own. So the width it checks is the result box’s width, not the page’s.
This simple version has two gaps. If query changes, the state keeps the old answer until the screen changes again. And a change between the first render and the Effect is missed, because nobody is listening yet. React has a hook made for values that live outside React, useSyncExternalStore. It reads the value on every render and subscribes for you, so it fixes both. Part 16 showed React’s docs using it for the online status.
useOnScreen: is a tag visible?
IntersectionObserver is a browser tool. It tells you when a tag comes into view, or goes out of view. Pages use it to load pictures late, or to load more items at the end of a list.
import { useEffect, useRef, useState, type RefObject } from 'react'
function useOnScreen(ref: RefObject<HTMLElement | null>) {
const [visible, setVisible] = useState(false)
useEffect(() => {
const node = ref.current
if (!node) return
const observer = new IntersectionObserver(entries => {
setVisible(entries[0].isIntersecting)
})
observer.observe(node)
return () => observer.disconnect()
}, [ref])
return visible
}
export default function App() {
const boxRef = useRef<HTMLDivElement>(null)
const visible = useOnScreen(boxRef)
return (
<div>
<p>The green box is {visible ? 'on screen' : 'off screen'}.</p>
<div style={{ height: 150, overflow: 'auto', border: '1px solid gray' }}>
<div style={{ height: 300 }}>Scroll down inside this area.</div>
<div ref={boxRef} style={{ height: 50, background: 'lightgreen' }} />
</div>
</div>
)
}
Run it, and scroll down inside the bordered area. When the green box comes into view, the text changes to “on screen”.
Same shape as useClickOutside: the component owns the tag and passes a ref. The cleanup calls disconnect(), which stops the watching. This simple version expects the tag to be on the page from the first render.
Putting behaviours together
A component can use several behaviour hooks at once. Each one adds one thing. Here is a modal that closes on a click outside, and on Escape. Both hooks use useEffectEvent. So neither one sets up its listener again when the modal renders.
import { useEffect, useEffectEvent, useRef, useState, type RefObject } from 'react'
function useKeyPress(key: string, onPress: () => void) {
const onKey = useEffectEvent(onPress)
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === key) onKey()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [key])
}
function useClickOutside(ref: RefObject<HTMLElement | null>, onOutside: () => void) {
const onPressOutside = useEffectEvent(onOutside)
useEffect(() => {
function handlePointerDown(e: PointerEvent) {
const box = ref.current
if (box && !box.contains(e.target as Node)) onPressOutside()
}
document.addEventListener('pointerdown', handlePointerDown)
return () => document.removeEventListener('pointerdown', handlePointerDown)
}, [ref])
}
function Modal({ onClose }: { onClose: () => void }) {
const boxRef = useRef<HTMLDivElement>(null)
useClickOutside(boxRef, onClose)
useKeyPress('Escape', onClose)
return (
<div style={{ background: 'rgba(0, 0, 0, 0.5)', padding: 24 }}>
<div ref={boxRef} style={{ background: 'white', color: 'black', padding: 16 }}>
<p>Save your changes?</p>
<button onClick={onClose}>Close</button>
</div>
</div>
)
}
export default function App() {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen(true)}>Open the modal</button>
{open && <Modal onClose={() => setOpen(false)} />}
</div>
)
}
Run it. Open the modal, and press Escape. Open it again, and click the dark area. Both close it.
Modal reads like a list of what it does: close on a click outside, close on Escape. Neither hook knows about the other, or about the white box. When the modal closes, both hooks clean up. In our test, 0 keydown and 0 pointerdown listeners were left on document after it closed.
Behaviour is not accessibility
A behaviour hook adds one behaviour. It doesn’t make a component easy to use for everyone.
Our modal closes on Escape, which helps keyboard users. But a real modal needs more. Another name for a modal is a dialog. Focus should move into it when it opens, and stay inside while it is open. It needs the right role, so a screen reader can tell what it is. When it closes, focus should go back to the button that opened it. None of our hooks do any of that.
useDisclosure set aria-expanded, and that helps. But it can’t check that the panel is near the button, or that the button is a real <button>. The component still owns all of that.
ARIA is a set of attributes, like aria-expanded, and roles. They tell screen readers what a tag is and what state it is in. Part 46 covers ARIA, and Part 47 covers keyboard navigation. For now, treat a behaviour hook as one piece, not the whole job.
Common mistakes
Adding a listener with no cleanup
import { useEffect } from 'react'
function useKeyPress(key: string, onPress: () => void) {
useEffect(() => {
document.addEventListener('keydown', e => {
if (e.key === key) onPress()
})
}, [key, onPress])
}
What goes wrong: the listener is never removed. We tested a menu with this hook, with Strict Mode on. Right after Run, it had 2 listeners, so one Escape called onPress twice. Each render added one more. After the menu was removed, all its listeners were still there.
The fix: name the function, and return a cleanup that removes that same function. That’s Part 12’s rule.
A new object or function in the array
useKeyPress with onPress in its array set up its listener again after every render. An options object does the same thing:
import { useEffect } from 'react'
type Keys = { ctrl?: boolean }
function useShortcut(key: string, onPress: () => void, options: Keys) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === key && e.ctrlKey === Boolean(options.ctrl)) onPress()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [key, onPress, options])
}
What goes wrong: a caller writes useShortcut('k', openSearch, { ctrl: true }). That { ctrl: true } is a new object on every render. We tested it with an onPress that was the same function every time. The listener was still removed and added again after each render. Part 11 explained why: React compares the array with Object.is, and two objects are never the same object.
The fix: take plain values, like ctrl: boolean, and put those in the array. Wrap onPress with useEffectEvent.
A stale handler
Leaving onPress out of the array, with nothing else changed, makes the listener call the first render’s handler forever. You saw it say “count was 0” after three clicks. The fix is useEffectEvent, or a latest ref on older React.
Listening for click on document
This looks like the same hook, with 'click' in place of 'pointerdown':
import { useEffect, useRef, useState, type RefObject } from 'react'
function useClickOutside(ref: RefObject<HTMLElement | null>, onOutside: () => void) {
useEffect(() => {
function handleClick(e: MouseEvent) {
const box = ref.current
if (box && !box.contains(e.target as Node)) onOutside()
}
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [ref, onOutside])
}
function Modal({ onClose }: { onClose: () => void }) {
const boxRef = useRef<HTMLDivElement>(null)
useClickOutside(boxRef, onClose)
return (
<div ref={boxRef}>
<p>Save your changes?</p>
<button onClick={onClose}>Close</button>
</div>
)
}
export default function App() {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen(true)}>Open the modal</button>
{open && <Modal onClose={() => setOpen(false)} />}
</div>
)
}
Press Run, then press “Open the modal”. Nothing seems to happen. The modal closes in the same click that opened it.
Here is why. A click travels up the page: from the button, through each parent tag, to document. React listens at the root of your app, on the way up. There it runs setOpen(true). When React’s listener returns, the browser lets React finish its update before it passes the click on. React renders, puts the modal on the page, and runs the new Effects at once. React’s list of changes for version 18 says this. Effects from a click or a key press now always run at once.
So the modal’s Effect adds its click listener to document while the click is still on its way. Then the same click reaches document. The “Open the modal” button is outside the modal, so the hook closes it.
We tested this in three real browsers: Chromium (the base of Chrome), Firefox, and WebKit (the base of the Safari browser). With 'click', a mouse click never opened the modal in any of them. Pressing Enter or Space on the focused button also closed it at once in Chromium and WebKit. In Firefox, the keys opened it. With 'pointerdown', the modal opened and stayed open every time. The press that opened it happened before the listener existed. Our jsdom test can’t show this, because there React’s update runs only after the click has ended.
The fix: listen for pointerdown, as our useClickOutside does.
Spreading the hook’s props, then adding your own
<button {...buttonProps} onClick={mine}> replaces the hook’s onClick. The last prop wins, so the hook’s behaviour stops. Use a prop getter, getButtonProps({ onClick: mine }), so the hook can call both.
Practice
Press Edit on the examples above and try these. Strict Mode is on in the playground.
- In “A new listener after every render”, press Run and click “Add one” three times. Don’t press S. How many “add listener” lines does the Console show in total?
- In the debounce example, change
500to2000. Type “cat” quickly. About how long after the last key does “Searching for” change? - In the menu from “One hook, two kinds of markup”, make the menu also close on Escape. Copy
useKeyPressfrom “Putting behaviours together” into the file. How much of theDropdownmarkup do you need to change? - In the
useEffectEventexample, add the lineonKey()insideuseKeyPress, right afterconst onKey = useEffectEvent(onPress). Press Run. What happens?
Answers
-
- Run prints “add listener” twice: the setup, and Strict Mode’s second setup. Each of the three clicks causes one render, and each render adds the listener again. So 2 + 3 = 5. Strict Mode doesn’t add an extra setup after a click, only when the component is first added.
- About 2 seconds. Each key clears the old timer and starts a new one, for 2000 milliseconds. Only the timer from the last key finishes.
- None of it. Add one line to
Dropdown:useKeyPress('Escape', () => setOpen(false)). You also needuseEffectEventin theimportline. Open the menu, press Escape, and it closes. - The result goes empty, with the error
A function wrapped in useEffectEvent can't be called during rendering.You called the Effect Event whileAppwas rendering. Effect Events may only be called from inside an Effect.
Interview questions
Try to answer each one out loud before you open the answer.
What is a behavioural hook? How is it different from a component?
It is a custom hook that adds a behaviour, like closing on a click outside, without returning any markup. A component returns tags. A behavioural hook takes a ref, or returns nothing, a value, or props. The component that calls it keeps full control of its own tags. So the same hook works with very different markup.
A strong answer names the shapes. The hook takes a ref, gives back props or a prop getter, or gives back a value. It also says each call gets its own Effect and its own state. Hooks share logic, not state.
How would you write a hook that closes a menu on a click outside it?
Take a ref and a function. In an Effect, add a pointerdown listener to document. In the listener, check ref.current.contains(e.target). If the press was not inside, call the function. Return a cleanup that removes the same listener function.
A strong answer explains why pointerdown and not click. Since React 18, React runs the Effects from a click at once, before the click reaches document. So a new click listener there can hear the same click that opened the box. It also wraps the function in useEffectEvent. Then a new arrow function on each render doesn’t set up the listener again.
It may name the edge cases. A tag drawn through a portal (Part 42) is not inside the box for contains. So a press on it counts as outside. A finger that starts a scroll also fires pointerdown. So the menu can close when the user only wanted to scroll. Older code uses mousedown and touchstart instead.
What is a prop getter, and why is it a function and not an object?
A function the hook returns, like getButtonProps(). You call it and spread the result onto your tag. It is a function so that you can pass your own props in. The hook then combines them with its own. For example, it calls both your onClick and its own. With a plain object, your onClick and the hook’s are two props with one name, and the last one wins.
A strong answer names Downshift, the library that made the pattern known. It may add that a getter can also set attributes the hook knows about, like aria-expanded.
A hook adds a key listener once, but the handler always sees old state. Why, and how do you fix it?
The Effect ran once, so the listener holds the handler from the first render. That handler kept the first render’s values, so it is stale. If you put the handler in the dependency array, it stays fresh. But a handler written in place is new on every render. So the listener is removed and added again after each render.
The fix is to keep the listener and call the newest handler. In React 19.2 and later, wrap the handler with useEffectEvent inside the hook. On older React, store it in a ref and update the ref in an Effect after every render. That is the latest ref pattern.
A strong answer adds the caller’s side. The caller can keep the handler the same between renders with useCallback, from Part 21. The React Compiler, in Part 22, can do that for you. But a hook shouldn’t count on its caller doing either.
What is useEffectEvent? What are its rules?
A hook, added in React 19.2. It wraps a function. When you call the result from an Effect, it uses the newest committed props and state. The Effect doesn’t need it in its dependency array, so the Effect doesn’t re-run when the function changes.
The rules from React’s docs: call it only from inside Effects or other Effect Events. Don’t call it while rendering. Don’t pass it to other components or hooks. Don’t put it in a dependency array. Don’t use it to hide a value the Effect should react to. A strong answer adds that its identity changes on every render, on purpose.
How do you debounce a value in React?
Keep the debounced value in state. In an Effect that depends on the value and the delay, start a setTimeout that sets the debounced value. Return a cleanup that clears the timer. Each new value clears the old timer, so only the last one finishes.
A strong answer says that the cleanup is what makes it work. It may add that a search still needs to ignore old answers, as Part 12 showed.
Does a click-outside hook make a modal accessible?
No. It adds one way to close the modal, for mouse and touch users. A real modal needs more. Focus must move into it and stay there. It needs the right role, so screen readers can tell what it is. Escape should close it. When it closes, focus should go back to the button that opened it.
A strong answer says each behaviour hook is one piece. Whether a component is accessible depends on the whole component, not on one hook.
Sources
- useEffectEvent, react.dev: what an Effect Event is, “always accesses the latest committed values from render”, where it may be called, not in dependency arrays, not to hide dependencies, its identity changes on every render, the error for calling it while rendering, and its use inside custom hooks.
- Separating Events from Effects, react.dev: why some logic in an Effect should not make it re-run.
- React 19.2, React blog, and the React changelog:
useEffectEventis new in 19.2.0. The 18.0.0 section, “Consistent useEffect timing”: Effects from an update made during a click or a key press run at once. - Reusing Logic with Custom Hooks, react.dev: custom hooks share logic, not state.
- useEffect and StrictMode, react.dev: setup and cleanup order, and the extra setup and cleanup in development.
- useSyncExternalStore, react.dev: subscribing to a browser API, with
navigator.onLineas the example. - eslint-plugin-react-hooks, react.dev: the linter rules we ran.
- Kent C. Dodds, How to give rendering control to users with prop getters (2017), and the Downshift README: the name “prop getter”, “a function which will return props when called”, and calling both click handlers.
- WAI-ARIA Authoring Practices, Dialog (Modal) Pattern: focus moves into the dialog, Tab stays inside it, Escape closes it, and focus returns to the element that opened it.
- MDN: aria-expanded (“whether or not the controlled elements are displayed or hidden”), Intersection Observer API (lazy-loading and “infinite scrolling”), pointerdown (mouse, touch and pen), Node.contains, KeyboardEvent.key (
"Escape"), Window.matchMedia and IntersectionObserver. - The
usehooks-tslibrary’s useEventCallback: a latest ref updated in a layout Effect. - The listener counts, the logs, the debounce timings, the prop clash results and the error text come from running React 19.3.0 for this post, with Strict Mode on and off. The
clickondocumenttest, the button order and the menu width were run in the playground in Chromium, Firefox and WebKit. The TypeScript message comes from TypeScript 7.0.2. The linter messages come from ESLint 10.12.0 witheslint-plugin-react-hooks7.1.1. - This part follows the Behavioral Hooks kata in react-katas. The kata uses
useCallbackto keep handlers the same between renders. This part usesuseEffectEventinstead, which React 19.2 added for this job.