Getting started
Installation
Add the package, use a hook, and your component reads browser state correctly on the server, during hydration, and from its first frame in the browser.
Install
npm install @vincent.ri/react-tilsNo dependencies besides React. Works with React 18 and 19.
Your first hook
Import a hook and call it like any other. This sidebar collapses on small screens, remembers the user's choice, and warns when the network drops:
'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>
)
}Not sure which hook you need? Browse all hooks, or press / to search.
Server rendering
The server can't see the browser, so hooks that read browser state take an optional serverValue: what to render on the server and during hydration. Right after hydration the hook switches to the real value, with no hydration mismatch. On a client-side mount, such as after navigation, 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 time every secondPick the serverValue most 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().
Next.js
Hooks run in client components, so add 'use client' to the file that calls them. When the server does know the value, because it arrives with the request, read it in a server component and pass it down as serverValue. The first frame is then already right:
// app/settings/page.tsx (server component)
import { cookies } from 'next/headers'
import { Settings } from './settings'
export default async function Page({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
const theme = (await cookies()).get('theme')?.value ?? null
const { tab = null } = await searchParams
return <Settings serverTheme={theme} serverTab={tab} />
}
// app/settings/settings.tsx
'use client'
import { useCookie, useSearchParam } from '@vincent.ri/react-tils'
export function Settings({ serverTheme, serverTab }: { serverTheme: string | null; serverTab: string | null }) {
const [theme, setTheme] = useCookie('theme', serverTheme)
const [tab, setTab] = useSearchParam('tab', serverTab)
// …
}Importing
Import from the package root or from a hook's own path. Both tree-shake, so you only ship the hooks you use, each a few hundred bytes.
import { useMediaQuery } from '@vincent.ri/react-tils'
import { useMediaQuery } from '@vincent.ri/react-tils/useMediaQuery'Every hook's docs and examples are in its type declarations, so your editor shows them on hover.
Set up your AI agent
Coding 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 the library's guide. Running it again changes nothing. In our evaluation, this one line took agent solutions with a wrong first frame from 17 of 27 (Claude Haiku) and 14 of 27 (Claude Sonnet) to 0 of 27 for both.
Testing
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: nothing to configure.
- Next.js (App Router), Vite, and any bundler with ESM support.
- TypeScript with
moduleResolutionset tobundlerornodenext. - ESM only. Vitest works out of the box; Jest needs its ESM mode, or
transformIgnorePatternsset so it transforms@vincent.ri/react-tils.