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.

Get startednpm i @vincent.ri/react-tils

Scrolling up · bottom of page in view: no

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.

narrowlight
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.

0×0
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.

0×0 · bottomInset 0
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.

y = 0
const { y } = useWindowScroll()
return y > 400 ? <button onClick={() => window.scrollTo({ top: 0 })}>Top</button> : null

useScrollDirection

The direction the page last scrolled: 'up' or 'down'.

Scroll down, then up. Also shown in the status bar.

scrolling up
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.

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.

read: 0%
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.

0pxborder box 0px
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.

Row 1
Row 2
Row 3
Row 4
Row 5
Row 6
Row 7
Row 8
Row 9
Row 10
Row 11
Row 12
Row 13
Row 14
Row 15
Row 16
Row 17
Row 18
Row 19
Row 20
Row 21
Row 22
Row 23
Row 24
Row 25
Row 26
Row 27
Row 28
Row 29
Row 30
y 0 · top yes · bottom yes
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.

footer in view: no
// 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.

status bar stuck: no
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.

following: yes
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.

in view no · width 0 · 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.

local 0
session 0
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.

demo-theme: not set
// 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.

Billingtab: profile · hash (none)
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.

q: (none) · sort: (none)
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.

received: nothing yet
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.

online
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.

unknown · data saver off
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.

visiblefocused
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.

active
const idle = useIdle(5 * 60_000)
return idle ? <Dialog>Are you still there?</Dialog> : null

usePreferredLanguages

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.

en
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.

permission: unknown
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.

false
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.

restored from cache: 0Go to the GitHub repo
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.

no readings
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.

last key: none
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.

palette closedg i pressed: 0
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.

held 0 · clicked 0
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.

last swipe: none
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.

left
right
left pane: 140px
// 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.

outside
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.

drop files here
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.

nothing pasted
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.

selected: —
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.

pressed: none
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.

focus inside: no · menu closed
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.

sent 0
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.

not focused
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.

0 ticks
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.

not saved
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.

searched: — · calls 0
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.

debounced: —
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.

events 0 · saves 0 · saved 50
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.

frames 0
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>

useShare

Opens the device's native share sheet (Web Share API), and says whether it is available.

Opens the native share sheet where supported.

not supported
const { supported, share } = useShare()
const [copy, copied] = useCopyToClipboard()
return supported ? (
  <button onClick={() => share({ title: post.title, url: post.url })}>Share</button>
) : (
  <button onClick={() => copy(post.url)}>{copied ? 'Copied!' : 'Copy link'}</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.

normal
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.

not supported
const [cooking, setCooking] = useState(false)
const { supported } = useWakeLock(cooking)
return supported ? <Toggle checked={cooking} onChange={setCooking}>Keep screen on</Toggle> : null

useUserMedia

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.

off
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.

nothing recorded
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.

not supported
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.

not supported
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.

closed · received 0 · latest —
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.

idle
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.

update available: no
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.

parent: closed
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.

screen readers hear: —
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.

100 · was —
const previous = usePrevious(price)
const trend = previous === undefined ? '' : price > previous ? '▲' : price < previous ? '▼' : ''
return <span>{price} {trend}</span>