React hooks for browser state, right on the first frame.
68 small, tested hooks that render on the server, hydrate without mismatches, clean up after themselves, and compile with React Compiler. Every demo below is server-rendered at build time and hydrated in your browser.
npm i @vincent.ri/react-tilsScrolling · bottom of page in view:
Viewport and media
useMediaQuery
Tracks whether a CSS media query matches, and re-renders when it changes.
Resize the window, or switch your OS theme.
const isWide = useMediaQuery('(min-width: 768px)')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
// Mobile-first site: assume narrow on the server.
const isDesktop = useMediaQuery('(min-width: 1024px)', false)useWindowSize
The window's inner width and height in CSS pixels, updated on resize. Like window.innerWidth, the width includes a visible vertical scrollbar; for the width without it, read document.documentElement.clientWidth or use useElementSize on a full-width element.
Resize the window.
const { width, height } = useWindowSize()
return <canvas width={width} height={height} />useVisualViewport
The visual viewport: the part of the page actually visible, which shrinks when the on-screen keyboard opens on phones and moves with pinch zoom.
On a phone, open the keyboard: bottomInset grows.
const { bottomInset } = useVisualViewport()
return <form style={{ position: 'fixed', left: 0, right: 0, bottom: bottomInset }}><ChatInput /></form>useWindowScroll
The window's scroll position in CSS pixels, updated on scroll.
Scroll the page.
const { y } = useWindowScroll()
return y > 400 ? <button onClick={() => window.scrollTo({ top: 0 })}>Top</button> : nulluseScrollDirection
The direction the page last scrolled: 'up' or 'down'.
Scroll down, then up. Also shown in the status bar.
const direction = useScrollDirection()
return <header className={direction === 'down' ? 'header hidden' : 'header'}>…</header>useScrollSpy
The id of the section the user is reading: the last of ids whose element has scrolled past the top of the viewport (plus offset). At the bottom of the page the last section is active, even if it is too short to reach the top. null before the first section, on the server, and during hydration.
Scroll the page: the sidebar highlights the section you're reading.
const ids = ['install', 'usage', 'api']
const active = useScrollSpy(ids, { offset: 64 })
return (
<nav>
{ids.map((id) => (
<a key={id} href={`#${id}`} aria-current={active === id ? 'location' : undefined}>{id}</a>
))}
</nav>
)useScrollProgress
How far the user has scrolled through an element, from 0 (its top at the top of the viewport) to 1 (its bottom at the bottom of the viewport). Returns a callback ref for the element.
Scroll the page: the bar in the status bar fills up.
const [ref, progress] = useScrollProgress<HTMLElement>()
return (
<>
<div className="progress-bar" style={{ transform: `scaleX(${progress})` }} />
<article ref={ref}>…</article>
</>
)Elements
useElementSize
An element's size in CSS pixels, updated whenever it resizes. By default the content box (excluding padding and border); pass { box: 'border-box' } to include them, e.g. to match getBoundingClientRect() without transforms.
The box reports its own width.
const [ref, { width, height }] = useElementSize<HTMLDivElement>()
return (
<div ref={ref} style={{ height: 300 }}>
<Chart width={width} height={height} />
</div>
)useElementScroll
The scroll position of a scrollable element, and whether it is at each edge. Returns a callback ref for the element.
Scroll the list: shadows show where there is more.
const [ref, { atTop, atBottom }] = useElementScroll<HTMLDivElement>()
return (
<div ref={ref} style={{ overflow: 'auto', maxHeight: 400 }}
className={[!atTop && 'shadow-top', !atBottom && 'shadow-bottom'].filter(Boolean).join(' ')}>
<Rows />
</div>
)useInView
Whether an element is visible in the viewport, using IntersectionObserver.
Watches the footer at the very bottom of the page.
// Load more when the sentinel gets within 300px of the viewport.
const [ref, inView] = useInView<HTMLDivElement>({ rootMargin: '300px' })
useEffect(() => {
if (inView) loadMore()
}, [inView])
return <div ref={ref} />useIsStuck
Whether a position: sticky element is currently stuck to the top of the viewport. Returns a callback ref for the sticky element.
The status bar at the top gets a shadow once it sticks.
const [ref, stuck] = useIsStuck<HTMLElement>()
return (
<header ref={ref} style={{ position: 'sticky', top: 0 }} className={stuck ? 'shadow' : ''}>
…
</header>
)useStickToBottom
Keeps a scrollable element scrolled to the bottom as content is added or grows, like a chat or a log, until the user scrolls up to read. Scrolling back to the bottom, or calling scrollToBottom, follows again.
Stream a reply, then scroll up while it streams.
Hi!
Ask me anything.
const [ref, { atBottom, scrollToBottom }] = useStickToBottom<HTMLDivElement>()
return (
<>
<div ref={ref} style={{ overflowY: 'auto', height: 500 }}>
{messages.map((m) => <Message key={m.id} {...m} />)}
</div>
{!atBottom && <button onClick={() => scrollToBottom()}>Jump to latest</button>}
</>
)useMergedRefs
Combines several refs into one callback ref, so one element can have a useRef, a forwarded ref prop, and refs from hooks such as useInView and useElementSize at the same time.
Three refs on one box: useInView, useElementSize, useRef.
function Section({ ref }: { ref?: Ref<HTMLElement> }) {
const [inViewRef, inView] = useInView<HTMLElement>()
const [sizeRef, { width }] = useElementSize<HTMLElement>()
return <section ref={useMergedRefs(ref, inViewRef, sizeRef)}>…</section>
}Storage and URL
useLocalStorage · useSessionStorage
State that persists in localStorage as JSON, stays in sync across components and browser tabs, and is safe to server-render.
State that persists in sessionStorage as JSON for the life of the tab, stays in sync across components, and is safe to server-render.
Reload: both stay. Open a second tab: local syncs, session does not.
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')
setTheme('dark')
const [count, setCount] = useLocalStorage('count', 0)
setCount((c) => c + 1)
const [step, setStep] = useSessionStorage('checkout-step', 1)
setStep((s) => s + 1)
const [dismissed, setDismissed] = useSessionStorage('promo-dismissed', false)useCookie
A cookie's value, a setter, and a remover. Unlike browser-only state, a cookie also reaches the server with the request: pass the request's value as serverValue, and the server render, hydration, and the browser all agree from the first frame.
Set it, then reload: the cookie is still there, and sent to the server too.
// Server component: read the cookie from the request and pass it down.
const theme = (await cookies()).get('theme')?.value ?? null
return <ThemeToggle serverTheme={theme} />
// Client component
function ThemeToggle({ serverTheme }: { serverTheme: string | null }) {
const [theme, setTheme] = useCookie('theme', serverTheme)
const next = theme === 'dark' ? 'light' : 'dark'
return <button onClick={() => setTheme(next, { maxAgeSeconds: 60 * 60 * 24 * 365 })}>{next}</button>
}useHash
The URL hash, like location.hash ('#pricing', or '' when there is none), and a setter. Re-renders on hash links, back/forward, setHash, and router navigation that uses history.pushState (e.g. Next.js <Link href="#pricing">, in browsers with the Navigation API).
Pick a tab, then reload or press Back.
const [hash, setHash] = useHash()
const tab = hash === '#billing' ? 'billing' : 'profile'
return <button onClick={() => setHash('#billing')}>Billing</button>useSearchParam
One URL query parameter (?tab=billing) as state: its value, or null when it's missing, and a setter that updates the URL without reloading. Works with or without a router: re-renders on set (in any component), Back and Forward, and router navigation that uses history.pushState (in browsers with the Navigation API).
Type, pick a sort, then reload or press Back.
const [query, setQuery] = useSearchParam('q')
return <input value={query ?? ''} onChange={(e) => setQuery(e.target.value || null)} />
// Tabs where Back returns to the previous tab
const [tab, setTab] = useSearchParam('tab')
return <button onClick={() => setTab('billing', { push: true })}>Billing</button>useBroadcastChannel
Sends and receives messages between tabs, windows, and iframes of the same origin, over a BroadcastChannel.
Open this page in two tabs, then send.
const post = useBroadcastChannel<{ type: 'logout' }>('auth', (message) => {
if (message.type === 'logout') router.replace('/login')
})
const logout = async () => {
await api.logout()
post({ type: 'logout' })
}Page, device, and user
useOnline
Whether the browser has a network connection, and re-renders when it changes.
Turn off your network.
const online = useOnline()
return online ? null : <Banner>You are offline. Changes will sync later.</Banner>useNetworkInformation
The connection's data-saver setting and rough quality, from the Network Information API, updated when it changes.
Chromium only; unknown elsewhere.
const { saveData, effectiveType } = useNetworkInformation()
const lite = saveData || effectiveType === '2g' || effectiveType === 'slow-2g'
return lite ? <img src={poster} alt="" /> : <video src={clip} autoPlay muted loop />useDocumentVisibility · useWindowFocus
The page's document.visibilityState ('visible' or 'hidden'), updated when the user switches tabs, minimizes the window, or locks the screen.
Whether the browser window has focus, updated on focus and blur.
Switch tabs or windows and come back.
const visibility = useDocumentVisibility()
useEffect(() => {
if (visibility === 'hidden') return
const id = setInterval(refresh, 5000)
return () => clearInterval(id)
}, [visibility])
const focused = useWindowFocus()
useEffect(() => {
if (focused) markAsRead(conversationId)
}, [focused, conversationId])useIdle
Whether the user has been inactive (no pointer, keyboard, touch, wheel, or scroll input) for timeoutMs. Becomes false again on the next activity.
Don't touch anything for 3 seconds.
const idle = useIdle(5 * 60_000)
return idle ? <Dialog>Are you still there?</Dialog> : nullusePreferredLanguages
The user's preferred languages from the browser, most preferred first (navigator.languages), updated when the user changes them.
From your browser's language settings.
const [language] = usePreferredLanguages()
const price = new Intl.NumberFormat(language, { style: 'currency', currency: 'EUR' }).format(9.99)usePermission · useGeolocation
The current state of a browser permission ('granted', 'denied', 'prompt'), updated when the user changes it in browser settings.
The device's position, watched with navigator.geolocation.watchPosition and updated as it moves. Stops watching on unmount or when disabled.
No prompt until you click.
const permission = usePermission('notifications')
if (permission === 'denied') return <p>Notifications are blocked in your browser settings.</p>
if (permission !== 'granted') return <button onClick={() => Notification.requestPermission()}>Enable notifications</button>
const [asked, setAsked] = useState(false)
const { loading, position, error } = useGeolocation({ enabled: asked })
if (!asked) return <button onClick={() => setAsked(true)}>Use my location</button>
if (loading) return <p>Locating…</p>
if (error) return <p>Could not get your location: {error.message}</p>
return <Map lat={position!.coords.latitude} lng={position!.coords.longitude} />useHydrated
false on the server and during hydration, true once the component is running in the browser.
false in the server HTML, true once hydrated.
const hydrated = useHydrated()
return <time>{hydrated ? date.toLocaleString() : date.toISOString()}</time>useNow
The current time as a timestamp in milliseconds, rounded down to intervalMs and updated every intervalMs. Ticks land on whole intervals (a 1000 ms clock changes exactly on the second), so every component using the same interval shows the same time.
A clock with no hydration mismatch: placeholder in the server HTML.
const now = useNow(1000)
return <time>{now === null ? '--:--:--' : new Date(now).toLocaleTimeString()}</time>
// Relative time, updated every minute.
const now = useNow(60_000)
const minutes = now === null ? 0 : Math.round((now - sentAt) / 60_000)usePageRestore
Calls callback when the browser restores the page from its back/forward cache, e.g. the user comes back with the Back button. The page reappears exactly as it was left: no reload, no new render, no effects re-run, so data shown on it may be stale.
Open another page in this tab, then press Back.
const { data, refetch } = useCart() // your data-fetching hook
usePageRestore(() => refetch())
// Reset a form that was left mid-submit before navigating away.
usePageRestore(() => setSubmitting(false))useDeviceOrientation
The device's orientation from its motion sensors: compass direction (alpha) and tilt (beta, gamma), updated as it moves.
On a phone, tilt it. Laptops usually have no sensor.
const { orientation, needsPermission, requestPermission } = useDeviceOrientation()
if (needsPermission) return <button onClick={requestPermission}>Enable tilt</button>
const tilt = orientation?.gamma ?? 0
return <div style={{ transform: `rotate(${tilt}deg)` }}>🧭</div>Events and input
useEventListener
Adds an event listener and removes it on unmount, like addEventListener in an effect, but always calls the latest handler without re-subscribing.
Press any key.
useEventListener('window', 'message', (event) => {
if (event.origin === 'https://widget.example.com') setData(event.data)
})
const ref = useRef<HTMLDivElement>(null)
useEventListener(ref, 'pointermove', (event) => setX(event.clientX), { passive: true })useClickOutside
Calls handler when the user presses outside an element: mouse, touch, or pen. Returns a callback ref to put on the element.
Open the menu, then click anywhere else.
const [open, setOpen] = useState(false)
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false), open)
// The ref wraps the button too, so pressing it toggles instead of closing and reopening.
return (
<div ref={ref}>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && <Menu />}
</div>
)useHotkey
Calls handler when a keyboard shortcut is pressed, e.g. 'Escape', 'mod+k', 'shift+/'.
Press ⌘K / Ctrl+K, then Escape. Or press g, then i.
useHotkey('mod+k', () => setPaletteOpen(true))
useHotkey('Escape', () => setPaletteOpen(false), { enabled: paletteOpen })useLongPress
Calls onLongPress when the user presses and holds an element, with mouse, touch, or pen. Returns event handlers to spread onto the element.
Click, or press and hold for half a second.
const longPress = useLongPress(() => setSelected(item.id))
return (
<li {...longPress} onClick={() => open(item)}>
{item.name}
</li>
)useSwipe
Calls onSwipe with 'left', 'right', 'up', or 'down' when the user swipes on an element, with touch, pen, or mouse. Returns a callback ref.
Swipe or drag across the box.
const ref = useSwipe<HTMLDivElement>((direction) => {
if (direction === 'left') next()
if (direction === 'right') previous()
})
return <div ref={ref} style={{ touchAction: 'pan-y' }}><Slide /></div>useDrag
Calls onDrag while the user drags an element with a mouse, finger, or pen. Returns a callback ref for the element (the handle).
Drag the bar between the panes, even past the edge.
// A splitter that resizes the sidebar.
const [width, setWidth] = useState(280)
const handle = useDrag(({ deltaX }) => setWidth((w) => Math.min(600, Math.max(160, w + deltaX))))
return (
<div style={{ display: 'flex' }}>
<aside style={{ width }}>…</aside>
<div ref={handle} role="separator" style={{ width: 6, cursor: 'col-resize', touchAction: 'none' }} />
<main>…</main>
</div>
)usePointerPosition
Where the mouse, pen, or finger is over an element, in pixels from its top-left corner, or null while it's outside (and on the server). Attach the returned ref to the element.
Move the mouse over the box, or touch it.
const [ref, pointer] = usePointerPosition<HTMLDivElement>()
return (
<div
ref={ref}
className="card"
style={{ '--x': `${pointer?.x ?? -999}px`, '--y': `${pointer?.y ?? -999}px` } as CSSProperties}
/>
)
// .card { background: radial-gradient(200px at var(--x) var(--y), #fff3, transparent) }useFileDrop
Turns an element into a drop zone for files, and reports when files are dragged over it.
Drag files from your desktop onto the box.
const [ref, isOver] = useFileDrop((files) => upload(files.filter((f) => f.type.startsWith('image/'))))
return (
<div ref={ref} className={isOver ? 'zone active' : 'zone'}>
Drop images here, or <input type="file" accept="image/*" multiple onChange={(e) => upload([...e.target.files!])} />
</div>
)usePasteFiles
Calls onFiles when the user pastes files, such as a screenshot, while focus is inside an element. Returns a callback ref for the element, e.g. a chat input or its wrapper.
Copy a screenshot, click the box, and paste. Text pastes normally.
const ref = usePasteFiles((files) => attach(files), { accept: 'image/*' })
return <textarea ref={ref} placeholder="Message, or paste a screenshot" />useTextSelection
The text the user has selected inside an element, or null. Returns a callback ref for the element.
Select some text with the mouse or keyboard.
Hooks for browser state that render the right first frame. Double-click anything here.
const [ref, selection] = useTextSelection<HTMLElement>()
return (
<>
<article ref={ref}>{children}</article>
{selection && (
<div style={{ position: 'fixed', top: selection.rect.top - 40, left: selection.rect.left }}>
<button onClick={() => quote(selection.text)}>Quote</button>
</div>
)}
</>
)useRovingFocus
Arrow-key navigation between the items of a toolbar, menu, tab list, listbox, or radio group, with a single Tab stop (a "roving tabindex"). Returns a callback ref for the container.
Tab into the toolbar, then use the arrow keys, or type a letter.
const ref = useRovingFocus<HTMLDivElement>({ orientation: 'horizontal' })
return (
<div ref={ref} role="toolbar" aria-label="Formatting">
<button>Bold</button>
<button>Italic</button>
<button>Underline</button>
</div>
)useFocusTrap
Keeps keyboard focus inside an element while active, e.g. a custom modal or drawer. Returns a callback ref for the element.
Open, then press Tab: focus stays inside.
function Drawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const ref = useFocusTrap<HTMLDivElement>(open)
useHotkey('Escape', onClose, { enabled: open })
if (!open) return null
return (
<div ref={ref} role="dialog" aria-modal="true" aria-label="Menu">
<button onClick={onClose}>Close</button>
<nav>…</nav>
</div>
)
}useFocusWithin
Whether focus is on the element or anything inside it, like CSS :focus-within, with callbacks when focus enters or leaves. Moving focus between children inside doesn't count as leaving.
Open the menu with the keyboard, then Tab past the last item.
const [open, setOpen] = useState(false)
const outsideRef = useClickOutside(() => setOpen(false), open)
const [focusRef] = useFocusWithin({ onLeave: () => setOpen(false) })
const ref = useMergedRefs(outsideRef, focusRef)
return (
<div ref={ref}>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && <ul>…</ul>}
</div>
)useAutosizeTextarea
Grows a <textarea> to fit its text, up to maxRows lines, then scrolls. Returns a callback ref for the textarea. Set the minimum with the textarea's own rows attribute.
Type several lines: it grows up to 5, then scrolls. Send clears it.
const [message, setMessage] = useState('')
const ref = useAutosizeTextarea({ maxRows: 8 })
return (
<textarea ref={ref} rows={1} value={message} onChange={(e) => setMessage(e.target.value)} />
)useCaretPosition
The caret's position on screen in a <textarea> or text <input>, or null while the field isn't focused. Returns a callback ref for the field.
Type @ anywhere in the text: the popup follows the caret.
const [ref, caret] = useCaretPosition()
const [text, setText] = useState('')
const mentioning = /@\w*$/.test(text)
return (
<>
<textarea ref={ref} value={text} onChange={(e) => setText(e.target.value)} />
{mentioning && caret && (
<ul style={{ position: 'fixed', top: caret.top + caret.height, left: caret.left }}>…</ul>
)}
</>
)Timers
useInterval
Calls callback every delayMs milliseconds. Pass null as the delay to pause. Always calls the latest callback, so it can read current state without restarting the timer.
Ticks every 500ms; pauses in background tabs.
const [count, setCount] = useState(0)
useInterval(() => setCount(count + 1), running ? 1000 : null)
// Poll every 5s, but not while the tab is in the background.
useInterval(refresh, 5000, { pauseWhenHidden: true })useTimeout
Calls callback once, delayMs after mount or after delayMs changes. Pass null to cancel. Always calls the latest callback. Cleared on unmount. Nothing runs on the server.
Shows for 2s after the latest save.
function Toast({ onClose }: { onClose: () => void }) {
const [hovered, setHovered] = useState(false)
useTimeout(onClose, hovered ? null : 5000)
return <div onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)}>Saved</div>
}
// Show "Saved" for 2s after the latest save.
const [saved, setSaved] = useState(false)
const restart = useTimeout(() => setSaved(false), 2000)
const onSave = () => { setSaved(true); restart() }useDebouncedCallback
Returns a debounced version of callback: it runs delayMs after the last call, with the arguments of that last call. The returned function is stable and always runs the latest callback, so it can read current state.
Type quickly: one search after you pause.
const search = useDebouncedCallback((query: string) => fetchResults(query), 300)
return <input onChange={(e) => search(e.target.value)} />
// Autosave while typing, at least every 5s, and save on unmount.
const save = useDebouncedCallback(saveDraft, 1000, { maxWaitMs: 5000 })
useEffect(() => () => save.flush(), [save])useDebouncedValue
Returns value, but only after it has stopped changing for delayMs. Starts as value (also on the server), then lags behind while it changes.
The debounced copy catches up after you pause.
const [query, setQuery] = useState('')
const debouncedQuery = useDebouncedValue(query, 300)
const results = useSearch(debouncedQuery) // your data-fetching hook
return <input value={query} onChange={(e) => setQuery(e.target.value)} />useThrottledCallback
Returns a throttled version of callback: it runs right away, then at most once every intervalMs while calls keep coming, and once more at the end with the arguments of the last call, so the final value is never lost. The returned function is stable and always runs the latest callback.
Drag the slider: saves every 500 ms, then once at the end.
const sendCursor = useThrottledCallback((x: number, y: number) => socket.send({ x, y }), 100)
return <div onPointerMove={(e) => sendCursor(e.clientX, e.clientY)} />useAnimationFrame
Calls callback on every animation frame while active, with the time since the previous frame. Always calls the latest callback, so it can read current props without restarting the loop. Cancelled on unmount and when active turns false. Browsers pause it in background tabs.
Moves without re-rendering; stops when paused.
const canvas = useRef<HTMLCanvasElement>(null)
const x = useRef(0)
useAnimationFrame((deltaMs) => {
x.current = (x.current + deltaMs * 0.1) % 300
const ctx = canvas.current?.getContext('2d')
ctx?.clearRect(0, 0, 300, 50)
ctx?.fillRect(x.current, 10, 30, 30)
}, playing)Browser APIs
useCopyToClipboard
Copies text to the clipboard and reports copied for a short time, for a "Copied!" label.
Copies a short text.
const [copy, copied] = useCopyToClipboard()
return <button onClick={() => copy(apiKey)}>{copied ? 'Copied!' : 'Copy'}</button>useFullscreen
Puts an element into fullscreen and tracks whether it is, including when the user leaves with Esc or a browser control.
Esc also exits.
const [ref, fullscreen] = useFullscreen<HTMLDivElement>()
return (
<div ref={ref}>
<Slides />
{fullscreen.supported && (
<button onClick={fullscreen.toggle}>{fullscreen.active ? 'Exit' : 'Fullscreen'}</button>
)}
</div>
)useWakeLock
Keeps the screen from dimming or locking while enabled is true, using the Screen Wake Lock API.
Keeps the screen on while checked.
const [cooking, setCooking] = useState(false)
const { supported } = useWakeLock(cooking)
return supported ? <Toggle checked={cooking} onChange={setCooking}>Keep screen on</Toggle> : nulluseUserMedia
Opens the camera and/or microphone with getUserMedia, and stops every track on unmount, when disabled, or when the constraints change, so the camera light turns off.
The camera light turns off when you stop.
const { stream, error } = useUserMedia({ video: { facingMode: 'environment' } })
const videoRef = useCallback(
(video: HTMLVideoElement | null) => {
if (video) video.srcObject = stream
},
[stream],
)
if (error) return <p>Camera unavailable: {error.message}</p>
return <video ref={videoRef} autoPlay playsInline muted />useMediaRecorder
Records a MediaStream (microphone, camera, or screen) into a file. Pair it with useUserMedia for the stream.
Record a few seconds from your microphone, then play it back.
const [on, setOn] = useState(false)
const { stream } = useUserMedia({ audio: true }, { enabled: on })
const recorder = useMediaRecorder(stream)
const finish = async () => {
const blob = await recorder.stop()
if (blob) await transcribe(new File([blob], 'note', { type: blob.type }))
setOn(false)
}
return recorder.recording
? <button onClick={finish}>Stop</button>
: <button onClick={() => (stream ? recorder.start() : setOn(true))}>{stream ? 'Record' : 'Allow microphone'}</button>useSpeechSynthesis
Reads text aloud with the browser's speech synthesis, with the voice list and whether it is speaking.
Reads the sentence below aloud.
React tils renders the right first frame. Hooks read browser state without hydration mismatches, and clean up after themselves.
const { supported, speaking, speak, cancel } = useSpeechSynthesis()
if (!supported) return null
return speaking
? <button onClick={cancel}>Stop</button>
: <button onClick={() => speak(answer, { rate: 1.1 })}>Read aloud</button>useSpeechRecognition
Turns speech from the microphone into text with the browser's built-in speech recognition: the final transcript, the words still being recognized, and whether it is listening.
Click and speak. Chrome, Edge, and Safari.
const speech = useSpeechRecognition({ lang: 'en-US' })
useEffect(() => {
if (speech.transcript) setQuery(speech.transcript)
}, [speech.transcript])
if (!speech.supported) return null
return (
<button onClick={speech.listening ? speech.stop : speech.start}>
{speech.listening ? 'Stop' : '🎤 Dictate'}
</button>
)useEventSource
Live updates from the server over Server-Sent Events (EventSource): connects while url is set, closes on unmount or when url changes or is null, and reconnects on its own.
A service worker stands in for the server: one tick a second.
const { status, data } = useEventSource(`/api/jobs/${id}/progress`)
const percent = data ? JSON.parse(data).percent : 0
return <progress value={percent} max={100} aria-busy={status !== 'open'} />
// Keep every message
const [items, setItems] = useState<Notification[]>([])
useEventSource('/api/notifications', {
onMessage: (event) => setItems((list) => [JSON.parse(event.data), ...list]),
pauseWhenHidden: true,
})useScript
Loads an external script once and reports its status: 'idle', 'loading', 'ready', or 'error'.
Loads a script once, on demand.
const status = useScript('https://js.stripe.com/v3/', { attributes: { nonce } })
if (status === 'error') return <p>Payments are unavailable right now.</p>
if (status !== 'ready') return <Spinner />
return <Checkout stripe={window.Stripe(publishableKey)} />useServiceWorkerUpdate
Whether a new version of your service worker (a PWA's offline code) is installed and waiting, and update() to switch to it and reload once.
This demo has no service worker, so there is never an update.
const { updateAvailable, update } = useServiceWorkerUpdate({ checkIntervalMs: 60 * 60_000 })
if (!updateAvailable) return null
return (
<div role="status">
A new version is available. <button onClick={update}>Reload</button>
</div>
)useScrollLock
Stops the page from scrolling while locked is true, e.g. behind a modal.
Opens a modal; the page can't scroll behind it.
function Modal({ open, children }: { open: boolean; children: React.ReactNode }) {
useScrollLock(open)
return open ? <div role="dialog" aria-modal="true">{children}</div> : null
}useBeforeUnload
Asks the browser to confirm before the user closes the tab, reloads, or navigates to another site, while when is true.
Check the box, then try to reload.
const [draft, setDraft] = useState('')
useBeforeUnload(draft !== savedDraft)Component helpers
useControllableState
State for a component that can be controlled (the parent passes value and onChange) or uncontrolled (the parent passes only a default), like a native <input> with value or defaultValue.
Same component: owns its state, or the parent does.
type Props = { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void }
function Disclosure({ open: openProp, defaultOpen = false, onOpenChange }: Props) {
const [open, setOpen] = useControllableState(openProp, defaultOpen, onOpenChange)
return <button aria-expanded={open} onClick={() => setOpen((o) => !o)}>Details</button>
}useAnnounce
Returns announce(message, politeness?), which reads a message to screen reader users without moving focus or showing anything on screen. The same message twice is announced twice.
Turn on a screen reader (VoiceOver: ⌘F5), then press a button.
const announce = useAnnounce()
const [copy] = useCopyToClipboard()
return <button onClick={async () => (await copy(url)) && announce('Link copied')}>Copy link</button>usePrevious
The value value had before it last changed, or undefined until it first changes. Re-renders that don't change value keep the same previous value, so it answers "what was it before?", not "what was it last render?".
Change the price; an unrelated re-render keeps the previous one.
const previous = usePrevious(price)
const trend = previous === undefined ? '' : price > previous ? '▲' : price < previous ? '▼' : ''
return <span>{price} {trend}</span>