@corndev/ui-hooks
v1.0.0-beta.4
Published
React hooks that every app writes anyway — debounced callbacks, disclosure, clipboard, media queries — with no dependencies beyond React
Maintainers
Readme
@corndev/ui-hooks
The React hooks an app ends up writing for itself when a library does not ship them.
No components, no CSS, no theme — you can take the hooks without taking the design system, and nothing here imports the rest of Corn UI.
npm install @corndev/ui-hooksReact 18 or newer, as a peer dependency.
useDebouncedCallback
Runs a callback once the caller stops calling it for a given number of milliseconds — the hook behind every text field that saves as you type.
import { useDebouncedCallback } from '@corndev/ui-hooks';
function Editor({ doc }) {
const save = useDebouncedCallback(text => api.put(doc.id, text), 250);
return (
<textarea
defaultValue={doc.text}
onChange={event => save(event.target.value)}
onBlur={save.flush}
/>
);
}save.flush() runs a pending call immediately, with the arguments it was going
to use. save.cancel() discards it.
It cancels on unmount, and that is correctness
A trailing call fires after the user stops typing, which may be after they have navigated away. A delayed write that lands then is writing to a store that no longer describes what is open — which is how a late save ends up stamping one document's contents onto another.
The hook cancels any pending call when the component unmounts, so that cannot happen. Nothing is required of the caller.
The callback that runs is the current one
The hook calls the most recent callback you passed, not the one captured when the timer was scheduled. A body that reads props or state sees the values from the render the timer fired in, rather than the ones from the render that scheduled it — the stale-closure trap that makes a debounced save write values the user has already changed.
Changing the delay
A call already pending keeps the delay it was scheduled with; a new delay applies to the next call. Restarting the clock instead would let a component that re-renders with a changing delay postpone a save indefinitely.
useDisclosure
An open/closed boolean with named controls, for the state behind a modal, a
drawer or a collapsible section. onOpen and onClose run only on a real
change, and the controls keep one identity, so they are safe in effect
dependencies.
import { useDisclosure } from '@corndev/ui-hooks';
const [opened, { open, close, toggle }] = useDisclosure(false, { onClose: reset });useClipboard
Copies text, and remembers for a moment that it did. copy resolves to whether
it worked and never rejects: without a secure (https) page, or when the browser
refuses, error says why.
import { useClipboard } from '@corndev/ui-hooks';
const { copy, copied } = useClipboard({ timeout: 2000 });
<button onClick={() => copy(url)}>{copied ? 'Copied' : 'Copy link'}</button>useMediaQuery
Whether a media query matches, kept current. Safe to render on the server: the
first render returns initialValue, and the real answer follows right after,
so hydration never mismatches.
import { useMediaQuery } from '@corndev/ui-hooks';
const narrow = useMediaQuery('(max-width: 960px)', { initialValue: false });useClickOutside
Calls a handler when the pointer goes down outside an element. Spread the
result onto the element rather than passing only a ref. It carries a
capture-phase handler that makes a portalled child, such as a dropdown rendered
into document.body, count as inside, because it is inside in React's tree.
import { useClickOutside } from '@corndev/ui-hooks';
const outside = useClickOutside(() => setOpen(false), { enabled: open });
<div {...outside}>…</div>ignore: [ref] adds elements outside React that should also count as inside.
useLocalStorage
State kept in localStorage, shared by every component using the key and kept
in step with other tabs.
import { useLocalStorage } from '@corndev/ui-hooks';
const [theme, setTheme, { remove, persistent }] = useLocalStorage('theme', 'light');- Server-safe: the first render returns the initial value, and the stored value follows.
- Degrades instead of throwing: in a private window, with blocked site data
or past the quota, the value lives in memory for the life of the page, and
persistentisfalse, so the UI can say a setting will not stick. - Values are JSON by default; pass
serializeanddeserializeto change that. A stored value that does not parse is treated as absent.
useHotkeys
Keyboard shortcuts for the page while the component is mounted. mod is ⌘ on
Apple platforms and Ctrl elsewhere.
import { useHotkeys } from '@corndev/ui-hooks';
useHotkeys([
['mod+k', openSearch],
['mod+s', save, { allowInInputs: true }],
['escape', close, { allowInInputs: true }],
]);Shortcuts stay quiet while focus is in a text field, a select or editable
content, so binding / does not steal the character from someone typing it.
Mark a binding allowInInputs to let it through. A matched shortcut's default
action is prevented.
useElementSize
An element's content-box size, kept current, for laying something out against
its container rather than the window. 0 × 0 on the server and until the
element is attached.
import { useElementSize } from '@corndev/ui-hooks';
const { ref, width } = useElementSize();
<div ref={ref}>{width > 600 ? <Wide /> : <Narrow />}</div>Server Components
Every hook is its own module with its own 'use client' directive, so importing
one from a file that also runs on the server does not pull the others into the
browser bundle.
Links
Licence
MIT © Corndog Development LLC
