# @vincent.ri/react-tils > React hooks for browser state and interaction that render the right first frame: SSR-safe, hydration-correct, tested in real browsers, and documented for AI coding agents. Install: `npm install @vincent.ri/react-tils`. Every hook renders on the server, hydrates without mismatch, and cleans up after itself. ## Pick a hook | Task | Use | | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | JS logic depends on viewport size, color scheme, reduced motion, pointer, orientation | `useMediaQuery(query, serverValue?)` | | Exact window width/height in pixels (canvas, virtualization, popover placement) | `useWindowSize(serverValue?)` | | Show offline state, disable network actions | `useOnline(serverValue?)` | | Pause polling/video/timers while the tab is hidden, refresh on return | `useDocumentVisibility(serverValue?)` | | Dark mode / reduced motion in JS | `useMediaQuery('(prefers-color-scheme: dark)')` | | Keep state for this tab only (form step, dismissed banner this visit) | `useSessionStorage(key, serverValue)` | | Scroll position (back-to-top button, hide header on scroll, progress bar) | `useWindowScroll(serverValue?)` | | Is an element on screen (lazy load, infinite list, play when visible) | `useInView(options?)` | | An element's pixel size (canvas/chart sized to its container) | `useElementSize({ box? })` | | Only styles depend on an element's size | No hook. Use CSS container queries. | | The window is visible but the user is in another window (pause, mark as read) | `useWindowFocus(serverValue?)` | | Render something only in the browser (local time, client-only widget) | `useHydrated()` instead of `useState(false)` + `useEffect` | | User's language for a default locale or `Intl` formatting | `usePreferredLanguages(serverValue?)` (pass the parsed `Accept-Language` header as `serverValue`) | | Show the right button for a permission (granted / denied / prompt) without prompting | `usePermission(name)` | | User inactive for a while ("Are you still there?", set status to away) | `useIdle(timeoutMs?)` | | User's location after they ask for it | `useGeolocation({ enabled })`; keep `enabled` false until a click | | Rough location (country, city) | No hook. Use IP-based location on the server; no prompt. | | Close a dropdown/popover/menu when the user presses outside it | `useClickOutside(handler, enabled?)` on a wrapper around the menu and its button, or native `` / `popover` | | Close a menu/popover/combobox when focus leaves it (Tab out), or save an inline editor | `useFocusWithin({ onLeave })` → `[ref, focused]`; pair with `useClickOutside` via `useMergedRefs` | | Keyboard shortcut (`mod+k` palette, Escape to close, `/` to search, `g i` sequences) | `useHotkey(hotkey, handler, options?)` | | "Copy" button with a "Copied!" label | `useCopyToClipboard(resetMs?)` | | Stop the page scrolling behind a modal or drawer | `useScrollLock(locked)` | | Warn before leaving with unsaved changes | `useBeforeUnload(when)` (does not catch client-side router navigation) | | Any other window/document/element event | `useEventListener(target, type, handler, options?)` | | Polling, countdown, clock, carousel | `useInterval(callback, delayMs \| null, { pauseWhenHidden? })` | | Live updates pushed by the server (notifications, job progress, prices, logs) | `useEventSource(url, { onMessage?, events?, pauseWhenHidden? })` → `{ status, data }`; not for POST/AI streams | | Auto-dismiss a toast, hide "Saved" after 2s, delay before showing a spinner | `useTimeout(callback, delayMs \| null)` → `restart` | | Reusable component, controlled or uncontrolled (`value` / `defaultValue` / `onChange`) | `useControllableState(value, defaultValue, onChange?)`; never copy a prop into state | | Floating toolbar for selected text (highlight, quote, "Ask AI about this") | `useTextSelection()` → `[ref, selection]` | | Render the current time (clock, countdown, "5 minutes ago") | `useNow(intervalMs, serverValue?)`; never `new Date()` in render | | Canvas drawing, game loop, per-frame updates | `useAnimationFrame(callback, active?)`; for UI animation use CSS or `element.animate()` | | Tabs or panels linkable through the URL hash (`/settings#billing`) | `useHash(serverValue?)` → `[hash, setHash]` | | Tabs, search, filters, sort, or page number kept in the URL query (`?tab=billing`) | `useSearchParam(name, serverValue?)` → `[value, set]`; `set(v, { push: true })` for a Back step; skip if your router has one | | One element needs several refs (forwarded `ref`, `useInView`, `useElementSize`) | `useMergedRefs(...refs)` | | Third-party SDK from a URL (Stripe.js, Maps, chat widget) | `useScript(src, { enabled?, attributes? })`; prefer an npm package with `import()` if one exists | | Keep the screen on (recipe, presentation, directions, ticket QR) | `useWakeLock(enabled)` | | Fullscreen button for a player, viewer, slideshow, or game | `useFullscreen()` | | Native "Share" sheet | `useShare()`, with `useCopyToClipboard` as the fallback | | Camera or microphone (video preview, QR scan, recording) | `useUserMedia(constraints, { enabled? })`; stops tracks on unmount | | Take a photo on a phone without a live preview | No hook. `` | | Tell other tabs about an event (logged out, cart updated) | `useBroadcastChannel(name, onMessage?)` → `post` | | Drag-and-drop file upload area | `useFileDrop(onDrop, { enabled? })` → `[ref, isOver]`; keep an `` too | | Press-and-hold on touch (select item, preview) | `useLongPress(onLongPress, { delayMs?, moveTolerance? })`; spread onto the element | | Search-as-you-type, autosave, validate after the user pauses | `useDebouncedCallback(fn, delayMs, { maxWaitMs? })`; for expensive rendering use `useDeferredValue` | | Debounced copy of state (a query to fetch with), not a debounced handler | `useDebouncedValue(value, delayMs)` | | Run during a burst at most every N ms (live results, send cursor/typing, slider save) | `useThrottledCallback(fn, intervalMs)`; to run once after the user stops, use `useDebouncedCallback` | | The previous value of a prop or state (price ▲/▼, slide direction, "changed from X") | `usePrevious(value)`; never a ref read during render | | Scroll position or edges of a scroll container (fade shadows, "read to the end") | `useElementScroll()` → `[ref, { y, atTop, atBottom, … }]`; for the page use `useWindowScroll` | | Chat, AI chat, or log that follows new messages until the user scrolls up | `useStickToBottom()` → `[ref, { atBottom, scrollToBottom }]` | | Highlight the current section in a table of contents or docs sidebar | `useScrollSpy(ids, { offset? })` → active id | | Chat input or comment box that grows with its text | `useAutosizeTextarea({ maxRows? })` → `ref`; set the minimum with `rows` | | Paste screenshots or files into a chat, editor, or upload area | `usePasteFiles(onFiles, { accept? })` → `ref`; pairs with `useFileDrop` | | Style a sticky header once it sticks (shadow, border, smaller) | `useIsStuck()` → `[ref, stuck]`; the element needs `position: sticky` | | Refresh stale data when the user comes back with the Back button | `usePageRestore(callback)` (back/forward cache) | | Resizable panel, splitter, custom slider, dragging on a canvas | `useDrag(onDrag, { threshold? })` → `ref`; add CSS `touch-action: none` | | Spotlight, tooltip, crosshair, or magnifier at the pointer over an element | `usePointerPosition()` → `[ref, { x, y } \| null]`; page-wide cursor: `useEventListener` + a CSS variable | | Tell screen reader users "Copied", "Added to cart", "3 results" | `useAnnounce()` → `announce(message, politeness?)` | | `@mention` or `/command` popup at the caret in a textarea | `useCaretPosition()` → `[ref, { top, left, height } \| null]` | | Record a voice note, voice input for AI chat, or a video message | `useMediaRecorder(stream)` with `useUserMedia`; picks a format the browser supports | | "Read aloud" button for AI answers or articles | `useSpeechSynthesis()` → `{ voices, speaking, speak, cancel }` | | "New version available, reload" banner in a PWA | `useServiceWorkerUpdate({ checkIntervalMs? })`; the worker must handle `SKIP_WAITING` | | Reading-progress bar for an article | `useScrollProgress()` → `[ref, progress]` from 0 to 1 | | A preference the server must know on the first request (theme, locale, currency) | `useCookie(name, serverValue)`; pass the request's cookie as `serverValue` | | Dictation: speech to text in a search box or chat input | `useSpeechRecognition({ lang?, continuous? })`; Chrome, Edge, Safari only | | Tilt effects, a compass, steering by tilting the phone | `useDeviceOrientation()`; on iOS call `requestPermission()` from a tap | | Arrow-key navigation in a toolbar, menu, tab list, or listbox (one Tab stop) | `useRovingFocus({ orientation?, loop? })`; radio groups and `