@vincent.ri/react-tils
v0.1.5
Published
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.
Maintainers
Readme
react-tils
React hooks for browser state and interaction that are correct on the first frame: they render on the server, hydrate without mismatches, clean up every listener, and work with React Compiler. Documented so AI coding agents pick the right hook and use it correctly.
Docs and live demo · Installation guide · Compare · llms.txt · Agent guide
Every hook's docs are on one URL, as a page or as markdown: …/hooks/useMediaQuery/ and …/hooks/useMediaQuery.md.
npm install @vincent.ri/react-tils'use client'
import { useLocalStorage, useMediaQuery, useOnline } from '@vincent.ri/react-tils'
export function Sidebar() {
const isDesktop = useMediaQuery('(min-width: 1024px)')
const [collapsed, setCollapsed] = useLocalStorage('sidebar-collapsed', false)
const online = useOnline()
return (
<aside data-collapsed={collapsed || !isDesktop}>
{!online && <p>You're offline. Changes will sync later.</p>}
<button onClick={() => setCollapsed((c) => !c)}>Toggle</button>
</aside>
)
}Why
The usual way to read browser state in React is useState plus useEffect. It works, but every client-side mount renders a wrong value first and corrects it a frame later. Reading window during render instead crashes on the server, and guessing on the server causes hydration mismatches. These bugs are easy to write and easy to miss, especially for AI agents.
- Right on the first frame. Hooks are built on
useSyncExternalStore. A client-side mount shows the real value immediately; server rendering and hydration use aserverValueyou control, with no mismatch. - No leaks. Every listener, observer, and timer is removed on unmount, including under StrictMode.
- Tested for real. Every hook passes a server-render, hydration, and leak check on React 18, 19, and canary, plus browser tests in Chromium, Firefox, WebKit, and a Next.js app.
- React Compiler safe. Every hook compiles with zero bailouts, and the full test suite runs again on compiled code.
- Small. No dependencies, one file per hook, tree-shakeable. All 68 hooks together are 12.1 kB (brotli).
- Made for agents. Every hook's JSDoc says when to use it and when not to, with examples.
AGENTS.mdmaps everyday tasks to hooks and lists the patterns to avoid.
Setup for AI agents
Agents rarely use an installed library unless something points them to it. One command adds that pointer:
npx react-tils initIt adds one line to your CLAUDE.md and AGENTS.md (creating them if needed), plus a Cursor rule if you use Cursor, pointing the agent at node_modules/@vincent.ri/react-tils/AGENTS.md. Running it again changes nothing.
In our evaluation, that one line took the share of agent solutions with a wrong first frame from 17/27 (Claude Haiku) and 14/27 (Claude Sonnet) to 0/27 for both. See Evaluation.
How server rendering works
Hooks that read browser state take an optional serverValue: what to render on the server and during hydration, where the browser state is unknown. After hydration the hook switches to the real value. On a client-side mount, such as after navigation, there is nothing to hydrate, so the real value renders immediately.
const isDesktop = useMediaQuery('(min-width: 1024px)', true) // assume desktop on the server
const now = useNow(1000) // null on the server, then the current time every secondPick the serverValue most of your visitors will have, so the switch after hydration is rare. For anything that must never show a default, render it only after hydration with useHydrated().
Hooks
Import from the root or from a hook's own path; both tree-shake:
import { useMediaQuery } from '@vincent.ri/react-tils'
import { useMediaQuery } from '@vincent.ri/react-tils/useMediaQuery'Every hook is documented in its type declarations, so your editor shows the full docs and examples on hover.
Viewport and media
| Hook | For | Size |
| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ----- |
| useMediaQuery(query, serverValue?) | Breakpoints, dark mode, reduced motion, pointer type in JS | 126 B |
| useWindowSize(serverValue?) | Exact window width and height in pixels | 168 B |
| useVisualViewport(serverValue?) | Keeping a bottom bar above the mobile keyboard | 285 B |
| useWindowScroll(serverValue?) | Page scroll position: back-to-top buttons, progress bars | 169 B |
| useScrollDirection(threshold?) | Hiding the header while scrolling down | 190 B |
| useScrollSpy(ids, options?) | Highlighting the current section in a table of contents | 266 B |
| useScrollProgress() | Reading-progress bars for articles | 288 B |
Elements
| Hook | For | Size |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ----- |
| useElementSize({ box? }) | An element's pixel size, for canvases and charts | 322 B |
| useElementScroll() | Scroll position and edges of a scroll container | 451 B |
| useInView(options?) | Lazy loading, infinite lists, play when visible | 252 B |
| useIsStuck() | Styling a sticky header once it sticks | 268 B |
| useStickToBottom() | Chats and logs that follow new messages until you scroll up | 589 B |
| useMergedRefs(...refs) | Putting several refs on one element | 315 B |
Storage and URL
| Hook | For | Size |
| ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ----- |
| useLocalStorage(key, serverValue) | State that survives reloads, synced across tabs | 368 B |
| useSessionStorage(key, serverValue) | State for this tab only | 370 B |
| useCookie(name, serverValue?) | Preferences the server also reads, correct on the first render | 446 B |
| useHash(serverValue?) | Tabs or panels linkable through the URL hash | 159 B |
| useSearchParam(name, serverValue?) | Tabs, search, and filters kept in the URL query | 310 B |
| useBroadcastChannel(name, onMessage?) | Telling other tabs about an event (logged out, cart) | 214 B |
Page, device, and user
| Hook | For | Size |
| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
| useOnline(serverValue?) | Offline banners, disabling network actions | 112 B |
| useNetworkInformation(serverValue?) | Skipping autoplay on data saver or slow connections | 228 B |
| useDocumentVisibility(serverValue?) | Pausing work while the tab is hidden | 101 B |
| useWindowFocus(serverValue?) | Whether the window has focus | 113 B |
| useIdle(timeoutMs?) | "Are you still there?", away status | 238 B |
| usePreferredLanguages(serverValue?) | The user's languages, for a default locale | 159 B |
| usePermission(name) | Showing the right button for a permission, without prompting | 319 B |
| useGeolocation(options?) | The user's location, after they ask for it | 305 B |
| useHydrated() | Rendering something only in the browser | 77 B |
| useNow(intervalMs, serverValue?) | Clocks, countdowns, "5 minutes ago" | 153 B |
| usePageRestore(callback) | Refreshing stale data after the Back button | 150 B |
| useDeviceOrientation() | Tilt effects and compasses, with the iOS permission flow | 331 B |
Events and input
| Hook | For | Size |
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ----- |
| useEventListener(target, type, handler, options?) | Any window, document, or element event | 220 B |
| useClickOutside(handler, enabled?) | Closing menus and popovers on an outside press | 187 B |
| useHotkey(hotkey, handler, options?) | Keyboard shortcuts: mod+k, Escape, and sequences like g i | 586 B |
| useLongPress(onLongPress, options?) | Press and hold on touch and mouse | 364 B |
| useSwipe(onSwipe, options?) | Swiping between slides, swipe to dismiss | 388 B |
| useDrag(onDrag, options?) | Resizable panels, splitters, custom sliders | 495 B |
| usePointerPosition() | Spotlights, tooltips, and crosshairs at the pointer | 198 B |
| useFileDrop(onDrop, options?) | Drag-and-drop file upload areas | 336 B |
| usePasteFiles(onFiles, options?) | Pasting screenshots and files into a chat or editor | 333 B |
| useTextSelection() | Floating toolbars for selected text | 351 B |
| useRovingFocus(options?) | Arrow keys and typeahead in toolbars, menus, and tab lists | 838 B |
| useFocusTrap(active?) | Keeping focus inside a custom modal or drawer | 512 B |
| useFocusWithin(options?) | Closing menus when keyboard users Tab out | 266 B |
| useAutosizeTextarea(options?) | Chat inputs that grow with their text | 474 B |
| useCaretPosition() | @mention and /command popups at the caret | 705 B |
Timers
| Hook | For | Size |
| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ----- |
| useInterval(callback, delayMs \| null, options?) | Polling, carousels; can pause while the tab is hidden | 243 B |
| useTimeout(callback, delayMs \| null) | Auto-dismissing toasts, "Saved" labels | 188 B |
| useDebouncedCallback(fn, delayMs, options?) | Search as you type, autosave | 281 B |
| useDebouncedValue(value, delayMs) | A debounced copy of state, such as a query to fetch | 99 B |
| useThrottledCallback(fn, intervalMs) | Live results while typing, sending cursor positions | 268 B |
| useAnimationFrame(callback, active?) | Canvas drawing and game loops | 173 B |
Browser APIs
| Hook | For | Size |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----- |
| useCopyToClipboard(resetMs?) | Copy buttons with a "Copied!" label | 177 B |
| useShare() | The native share sheet | 196 B |
| useFullscreen() | Fullscreen for players, viewers, and slideshows | 292 B |
| useWakeLock(enabled?) | Keeping the screen on | 313 B |
| useUserMedia(constraints, options?) | Camera and microphone | 313 B |
| useMediaRecorder(stream, options?) | Voice notes and voice input, in a format every browser can record | 698 B |
| useSpeechSynthesis() | "Read aloud" buttons | 631 B |
| useSpeechRecognition(options?) | Dictation into search boxes and chat inputs | 514 B |
| useEventSource(url, options?) | Live notifications, progress, and prices from the server | 547 B |
| useScript(src, options?) | Loading a third-party script once, on demand | 437 B |
| useServiceWorkerUpdate(options?) | "New version available" banners in PWAs | 418 B |
| useScrollLock(locked?) | Stopping the page scrolling behind a modal | 270 B |
| useBeforeUnload(when?) | Warning before leaving with unsaved changes | 105 B |
Component helpers
| Hook | For | Size |
| ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ----- |
| useControllableState(value, defaultValue, onChange?) | Components that work both controlled and uncontrolled | 204 B |
| useAnnounce() | Announcing "Copied" or "3 results" to screen readers | 346 B |
| usePrevious(value) | Price ▲/▼, slide direction, "changed from X to Y" | 92 B |
Coming from react-use, ahooks, usehooks-ts, or similar? AGENTS.md maps their hook names to these.
Testing your own hooks
The checks every hook here passes are exported, so you can run them on your own hooks and components with Vitest (in a happy-dom or jsdom environment):
import { describe } from 'vitest'
import { componentMatrix, hookMatrix } from '@vincent.ri/react-tils/testing'
import { Sidebar } from './Sidebar'
import { useScrollY } from './useScrollY'
describe('useScrollY', () => {
hookMatrix(() => useScrollY())
})
describe('Sidebar', () => {
componentMatrix(Sidebar)
})They fail if the code touches the DOM during server render, causes a hydration mismatch, or leaks a listener after a StrictMode mount and unmount.
Compatibility
- React 18 and 19, including React Compiler.
- Browsers: most hooks work in every modern browser. Each hook's docs page has a browser support table from MDN's data, and says what the hook does where an API is missing.
- Tested in Next.js (App Router) and Vite apps, with npm, pnpm, yarn, and bun. Any bundler with ESM support works.
- TypeScript with
moduleResolutionbundlerornodenext. - ESM only. Vitest works out of the box; Jest needs its ESM mode, or
transformIgnorePatternsset so it transforms@vincent.ri/react-tils. - Not in scope: data fetching, state management, and generic helpers like
useToggleoruseCounter.
Evaluation
Headless Claude Code agents were given 9 everyday tasks in a Next.js project (responsive sidebar, window width, theme toggle, offline banner, pause when hidden, dark mode, reduced motion, tabs from the URL hash, greeting in the browser's language), 3 runs each. Prompts were normal requests and never mentioned server rendering.
| Agent | Wrong first frame, no library | Library installed | Library + init pointer |
| ------------- | ----------------------------- | ----------------- | ------------------------ |
| Claude Haiku | 17/27 | 10/27 | 0/27 |
| Claude Sonnet | 14/27 | 12/27 | 0/27 |
Without the pointer, agents used the installed library in 10 of 54 runs; with it, in all 54. Hard failures were rare: Haiku's two or three hydration mismatches per setup went to zero with the pointer, and Sonnet passed every hard check in every setup. The common bug is the wrong first frame. Run with current Claude models only, in October 2026. Method, caveats, and how to reproduce it are in CONTRIBUTING.md.
Contributing
See CONTRIBUTING.md for development, the test setup, and releasing.
