Hooks
React hooks an app would otherwise write for itself — no components, no CSS, nothing else from Corn UI.
import { ... } from '@corndev/ui-hooks' Install
npm install @corndev/ui-hooks
Every hook is its own module with its own 'use client', so importing one from a Server Component file does not drag the others into the browser bundle.
Quick start
import { useDebouncedCallback } from '@corndev/ui-hooks';
function Editor({ doc }) {
// Runs 250ms after the user stops typing — and never after this component
// unmounts, which is the part that matters: a late save writes to a store
// that no longer describes what is open.
const save = useDebouncedCallback(text => api.put(doc.id, text), 250);
return <textarea defaultValue={doc.text} onChange={e => save(e.target.value)} onBlur={save.flush} />;
}
What is here
The hooks nearly every React app ends up writing: open/closed state, the clipboard, media queries, clicks outside an element, persisted state, keyboard shortcuts, element size, debouncing and tree state. Each one handles the case that usually goes wrong, and the API below says how.
It is a separate package so you can take the hooks without the design system. Nothing here imports the rest of Corn UI.
Server rendering
Every hook renders on the server. useMediaQuery and useLocalStorage return
their initial value on the server and in the first browser render, and the real
value follows right after, so hydration never mismatches. A test server-renders
each hook in Node, with no window.
Recipes
A dropdown that closes when you click away
import { useClickOutside, useDisclosure, useHotkeys } from '@corndev/ui-hooks';
function Dropdown({ children }) {
const [opened, { toggle, close }] = useDisclosure();
const outside = useClickOutside(close, { enabled: opened });
useHotkeys([['escape', close, { allowInInputs: true }]], { enabled: opened });
return (
<div {...outside}>
<button aria-expanded={opened} onClick={toggle}>Menu</button>
{opened && children}
</div>
);
}
Spread useClickOutside’s result onto the element. Besides the ref it carries a
handler that makes a child rendered into a portal count as inside.
A setting that survives a reload
import { useLocalStorage } from '@corndev/ui-hooks';
function DensityToggle() {
const [dense, setDense, { persistent }] = useLocalStorage('density', false);
return (
<label>
<input type="checkbox" checked={dense} onChange={e => setDense(e.target.checked)} />
Compact rows{!persistent && ' (this browser will not remember)'}
</label>
);
}
When storage is blocked, as in a private window, the value lives in memory for
the page’s lifetime and persistent is false.
API
useDebouncedCallback(callback, delay)
Runs callback once the caller stops calling it for delay milliseconds. It cancels on unmount, and that is correctness rather than politeness. A trailing call fires after the user has stopped typing, which may be after they have navigated away — and a delayed write that lands then is writing to a store that no longer describes what is open: a late save can stamp one document’s contents onto another. The latest callback is used when the timer fires, not the one captured when the debounced function was made, so a body that reads props or state sees current values rather than the ones from the render that scheduled it. Changing delay while a call is pending leaves that call on the delay it was scheduled with; the new delay applies to the next one. Restarting the clock instead would let a component that re-renders with a changing delay postpone a save indefinitely.
Returns T & DebouncedCallbackControls.
useTree(options)
Binds a tree’s state to a component. This is a wrapper, not an implementation. Everything it does lives in createTreeState from @corndev/ui, which works with no React at all — a spec outline rendered as an HTML string needs the same expanded set as a React file tree does. Behaviour added here rather than there would quietly give the vanilla consumer the worse tree, which is the drift the markdown package keeps a test for. Options are the ones createTreeState takes. The callbacks are read fresh each time they fire, so a handler closing over props is not the one captured on first render.
Returns { expanded: Set<string>, selected: string|null, loading: Set<string>, expand: (value: string) => void, collapse: (value: string) => void, toggle: (value: string) => void, select: (value: string|null) => void, isExpanded: (value: string) => boolean, }.
useDisclosure(initial, callbacks)
An open/closed boolean with named controls — the state behind a modal, a drawer, a collapsible section. onOpen and onClose run only on a real change, so calling open() on something already open does nothing. The controls keep one identity for the component’s life, so they can go into effect dependencies and memoised children freely.
Returns [boolean, DisclosureControls].
useClipboard(options)
Copies text to the clipboard and remembers, briefly, that it did — for a “Copy” button that says “Copied” for a moment. The clipboard API needs a secure context and, in some browsers, a user gesture; when it is missing or refuses, copy resolves false and error says why rather than throwing into an event handler. The copied timer is cancelled on unmount.
Returns ClipboardState.
useMediaQuery(query, options)
Whether a media query matches, kept current as it changes. Server-safe. There is no window on the server, so the first render — on the server, and the hydrating render in the browser — returns initialValue, and the real answer follows immediately after. Reading matchMedia during render instead throws on the server and mismatches hydration in the browser.
Returns boolean.
useClickOutside(handler, options)
Calls handler when the pointer goes down outside an element — the way a dropdown or popover closes when you click elsewhere. Spread the result onto the element, rather than only passing a ref: <div {...outside}>. Besides the ref it carries a capture-phase pointer handler, which is what makes a portal count as inside. A dropdown rendered into document.body is outside the element in the DOM but inside it in React’s tree, and React delivers its events through that tree — so a click on the dropdown reaches the handler here and is not mistaken for a click away. Nodes outside React entirely can be listed in ignore.
Returns { ref: (node: Element | null) => void, onPointerDownCapture: () => void }.
useLocalStorage(key, initialValue, options)
State kept in localStorage under key, shared by every component using the key and kept in step with other tabs. Server-safe. The first render — on the server, and the hydrating render in the browser — returns initialValue, and the stored value follows immediately after, so hydration never mismatches. It degrades instead of throwing. In a private window, with blocked site data, or past the quota, localStorage throws on access; the value then lives in memory for the life of the page, still shared between components, and persistent is false so the UI can say that a setting will not stick. Values go through JSON.stringify / JSON.parse by default; a stored value that does not parse is treated as absent.
Returns [T, (value: T | ((previous: T) => T)) => void, { remove: () => void, persistent: boolean }].
useHotkeys(hotkeys, options)
Keyboard shortcuts for the whole page while the component is mounted. Quiet while the reader is typing. A shortcut does not fire while focus is in a text field, a select or editable content — otherwise binding / to search or s to save would steal the character from whoever is typing it. Mark one binding { allowInInputs: true } to let it through, as mod+s or escape usually should be. A matched shortcut’s default action is prevented, so mod+s does not also open the browser’s save dialog. The handlers are read when a key is pressed, so inline functions work without re-subscribing.
Returns void.
useElementSize()
An element’s size, kept current as it changes — for laying something out against its container rather than the window. Reports the content box, the area children are laid out in. 0 × 0 on the server and until the element is attached; it is measured as soon as it is. The observer is disconnected on unmount and when the ref moves to another element.
Returns { ref: (node: Element | null) => void, width: number, height: number }.