Hooks
Cross-cutting React hooks — state, storage, media, keyboard, and more.
All hooks live at @jonmatum/next-shell/hooks. They are client-only ('use client' is set on the subpath) and SSR-safe.
State
useDisclosure
Open / close / toggle primitive for dialogs, drawers, and popovers:
import { useDisclosure } from '@jonmatum/next-shell/hooks';
function MyDialog() {
const { isOpen, open, close, onOpenChange } = useDisclosure();
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<Button onClick={open}>Open</Button>
<DialogContent>
<DialogTitle>Hello</DialogTitle>
<Button onClick={close}>Close</Button>
</DialogContent>
</Dialog>
);
}
useControllableState
Radix-style controlled / uncontrolled state — use when building components that accept both value and defaultValue:
import { useControllableState } from '@jonmatum/next-shell/hooks';
function Select({ value, defaultValue, onValueChange }) {
const [selected, setSelected] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange: onValueChange,
});
// ...
}
Storage
useLocalStorage / useSessionStorage
JSON-serialised, SSR-safe, with a functional updater and removeValue:
import { useLocalStorage } from '@jonmatum/next-shell/hooks';
const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'system');
setTheme('dark');
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
removeTheme();
Debouncing
useDebouncedValue
import { useDebouncedValue } from '@jonmatum/next-shell/hooks';
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search, 300);
useEffect(() => {
fetchResults(debouncedSearch);
}, [debouncedSearch]);
useDebouncedCallback
import { useDebouncedCallback } from '@jonmatum/next-shell/hooks';
const save = useDebouncedCallback(async (value: string) => {
await api.save(value);
}, 500);
Device / media
useMediaQuery
import { useMediaQuery } from '@jonmatum/next-shell/hooks';
const isDark = useMediaQuery('(prefers-color-scheme: dark)');
const isTouch = useMediaQuery('(pointer: coarse)');
useBreakpoint
Tailwind v4 default breakpoints — returns booleans and the current largest active breakpoint:
import { useBreakpoint } from '@jonmatum/next-shell/hooks';
const { isMd, isLg, current } = useBreakpoint();
// current: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | null
Keyboard
useHotkey
import { useHotkey } from '@jonmatum/next-shell/hooks';
useHotkey('k', openCommandBar, { meta: true }); // ⌘K
useHotkey('Escape', closeModal, { enabled: isOpen }); // conditional
useHotkey('s', save, { ctrl: true, meta: true }); // Ctrl+S / ⌘S
Lifecycle
useMounted
Returns true after the first render — use to avoid SSR/client mismatch in conditional rendering:
import { useMounted } from '@jonmatum/next-shell/hooks';
function ClientOnly({ children }) {
const mounted = useMounted();
if (!mounted) return null;
return <>{children}</>;
}
useIsomorphicLayoutEffect
useLayoutEffect on the client, useEffect on the server:
import { useIsomorphicLayoutEffect } from '@jonmatum/next-shell/hooks';
useIsomorphicLayoutEffect(() => {
// runs before paint on client, after render on server
}, [dep]);
Clipboard
useCopyToClipboard
import { useCopyToClipboard } from '@jonmatum/next-shell/hooks';
function CopyButton({ text }) {
const { isCopied, copy } = useCopyToClipboard(2000);
return <button onClick={() => copy(text)}>{isCopied ? 'Copied!' : 'Copy'}</button>;
}
i18n
useLocale
Returns the active locale string. Reads from I18nAdapter.locale if exposed, falls back to navigator.language:
import { useLocale } from '@jonmatum/next-shell/hooks';
import { formatCurrency } from '@jonmatum/next-shell/formatters';
function Price({ amount }) {
const locale = useLocale();
return <span>{formatCurrency(amount, 'USD', { locale })}</span>;
}