- TypeScript 100%
| .forgejo/workflows | ||
| src | ||
| .editorconfig | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
| yarn.lock | ||
Hooks
Shared React hooks library used across projects
Installation
yarn add @wt/hooks
# or
npm install @wt/hooks
Requirements
| Peer dependency | Version |
|---|---|
react |
>= 18.0.0 |
react-router-dom |
>= 6.0.0 (only required if using useQuery) |
Hooks
| Hook | Description |
|---|---|
useLocalStorage |
State synchronized with localStorage, with cross-tab sync |
useWindowResize |
Window dimensions and Tailwind-aligned breakpoint flags |
useDeviceInfo |
Device type detection (mobile / tablet / desktop) |
useQuery |
Parse URL query parameters via react-router-dom |
useSearch |
Debounced search orchestration with minimum character threshold |
useAddIdPrefix |
Format a numeric ID as a zero-padded #-prefixed string |
useToggle |
Boolean state with a convenience toggle function |
useDebounce |
Debounce any value by a configurable delay |
useKeyPress |
Run a callback when a specific key is pressed |
useEnterKeyPress |
Run a callback when Enter is pressed |
useEscapeKeyPress |
Run a callback when Escape is pressed |
useEventListener |
Attach event listeners to window or a specific element |
useIsMounted |
Check whether the component is still mounted |
useIsomorphicLayoutEffect |
useLayoutEffect in the browser, useEffect on the server |
API Reference
useLocalStorage
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void]
State that persists to localStorage. The setter accepts a direct value or a function updater, just like useState. Automatically syncs across tabs via the storage event and across hooks in the same tab via a custom local-storage event.
import { useLocalStorage } from '@wt/hooks';
function Counter() {
const [count, setCount] = useLocalStorage('counter', 0);
return (
<button onClick={() => setCount((prev) => prev + 1)}>
Count: {count}
</button>
);
}
useWindowResize
function useWindowResize(): {
width: number;
height: number;
isSmallDevice: boolean; // width <= 768
isSmallerDevice: boolean; // width <= 480
isXXS: boolean; // width < 375
isXS: boolean; // width >= 375 && < 640
isSM: boolean; // width >= 640 && < 768
isMD: boolean; // width >= 768 && < 1024
isLG: boolean; // width >= 1024 && < 1280
isXL: boolean; // width >= 1280
}
Returns the current window dimensions and Tailwind-aligned breakpoint flags. Updates on resize with a 100ms debounce. SSR-safe (all values default to 0 / false).
import { useWindowResize } from '@wt/hooks';
function Layout() {
const { width, isSmallDevice, isMD } = useWindowResize();
if (isSmallDevice) return <MobileNav />;
return <DesktopNav />;
}
useDeviceInfo
function useDeviceInfo(): {
width: number;
height: number;
device: DeviceInfo; // { isMobile, isTablet, isDesktop, type }
}
Returns window dimensions and a device object with mobile/tablet/desktop classification. Breakpoints: mobile <= 768, tablet <= 1024, desktop > 1024. Updates on resize with a 100ms debounce. SSR-safe.
import { useDeviceInfo } from '@wt/hooks';
function App() {
const { device } = useDeviceInfo();
return <p>Device type: {device.type}</p>; // "mobile" | "tablet" | "desktop"
}
useQuery
function useQuery(): URLSearchParams
Returns a URLSearchParams object for the current URL's query string. Must be used inside a react-router-dom <Router> context.
import { useQuery } from '@wt/hooks';
function SearchResults() {
const query = useQuery();
const page = query.get('page'); // "2" for ?page=2
return <p>Page: {page}</p>;
}
useSearch
function useSearch(
query: string,
minCharactersCallback: () => void,
searchFunction: () => void,
): { finishedSearch: boolean }
Search orchestration hook. When query has fewer than 3 characters, minCharactersCallback is called immediately (use this to clear results or hide loading states). When query has 3+ characters, searchFunction is called after a 500ms debounce. Returns finishedSearch to indicate when the search has fired.
import { useSearch } from '@wt/hooks';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const { finishedSearch } = useSearch(
query,
() => setResults([]),
() => fetchResults(query).then(setResults),
);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{!finishedSearch && query.length >= 3 && <Spinner />}
{results.map((r) => <Result key={r.id} data={r} />)}
</div>
);
}
useAddIdPrefix
function useAddIdPrefix(): (id: number) => string
Returns a formatting function that takes a numeric ID and returns it as a #-prefixed string, zero-padded to 6 digits.
import { useAddIdPrefix } from '@wt/hooks';
function OrderRow({ orderId }: { orderId: number }) {
const addPrefix = useAddIdPrefix();
return <td>{addPrefix(orderId)}</td>; // 25 → "#000025"
}
useToggle
function useToggle(initialState?: boolean): [
boolean,
React.Dispatch<React.SetStateAction<boolean>>,
() => void,
]
Boolean state hook that returns a 3-tuple: [state, setState, toggle]. setState is the standard React setter (for setting an explicit value), and toggle is a convenience function that flips the current state. Defaults to false.
import { useToggle } from '@wt/hooks';
function Modal() {
const [isOpen, setIsOpen, toggleOpen] = useToggle();
return (
<div>
<button onClick={toggleOpen}>Toggle</button>
<button onClick={() => setIsOpen(true)}>Force open</button>
{isOpen && <div className="modal">Content</div>}
</div>
);
}
useDebounce
function useDebounce<T>(value: T, delay?: number): T
Returns a debounced version of value that only updates after delay milliseconds of inactivity. Defaults to 500ms.
import { useDebounce } from '@wt/hooks';
function Search() {
const [input, setInput] = useState('');
const debouncedInput = useDebounce(input, 300);
useEffect(() => {
if (debouncedInput) fetchResults(debouncedInput);
}, [debouncedInput]);
return <input value={input} onChange={(e) => setInput(e.target.value)} />;
}
useKeyPress
function useKeyPress(target: string, callback: () => void): void
Listens for keydown events on document and calls callback when event.key matches target.
import { useKeyPress } from '@wt/hooks';
function Player() {
useKeyPress(' ', () => togglePlayback());
useKeyPress('ArrowRight', () => skipForward());
return <Video />;
}
useEnterKeyPress
function useEnterKeyPress(callback: () => void): void
Calls callback when the Enter key is pressed. Shorthand for useKeyPress('Enter', callback).
import { useEnterKeyPress } from '@wt/hooks';
function ChatInput() {
const [message, setMessage] = useState('');
useEnterKeyPress(() => sendMessage(message));
return <input value={message} onChange={(e) => setMessage(e.target.value)} />;
}
useEscapeKeyPress
function useEscapeKeyPress(callback: () => void): void
Calls callback when the Escape key is pressed. Shorthand for useKeyPress('Escape', callback).
import { useEscapeKeyPress } from '@wt/hooks';
function Dialog({ onClose }: { onClose: () => void }) {
useEscapeKeyPress(onClose);
return <div className="dialog">Content</div>;
}
useEventListener
// Listen on window
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
): void
// Listen on a specific element
function useEventListener<K extends keyof HTMLElementEventMap, T extends HTMLElement>(
eventName: K,
handler: (event: HTMLElementEventMap[K]) => void,
element: RefObject<T>,
): void
Type-safe event listener that attaches to window by default, or to a specific element when a RefObject is provided. The handler is stored in a ref to avoid stale closures. Cleans up automatically on unmount.
import { useEventListener } from '@wt/hooks';
// Window event
function ScrollTracker() {
useEventListener('scroll', () => {
console.log(window.scrollY);
});
return null;
}
// Element event
function Hoverable() {
const ref = useRef<HTMLDivElement>(null);
useEventListener('mouseenter', () => console.log('hovered'), ref);
return <div ref={ref}>Hover me</div>;
}
useIsMounted
function useIsMounted(): () => boolean
Returns a stable callback that returns true while the component is mounted and false after unmount. Useful for guarding async operations.
import { useIsMounted } from '@wt/hooks';
function UserProfile({ userId }: { userId: string }) {
const isMounted = useIsMounted();
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then((data) => {
if (isMounted()) setUser(data);
});
}, [userId]);
return user ? <p>{user.name}</p> : <Spinner />;
}
useIsomorphicLayoutEffect
const useIsomorphicLayoutEffect: typeof useLayoutEffect
Resolves to useLayoutEffect in the browser and useEffect on the server, avoiding the SSR warning React emits for useLayoutEffect. Same API as useEffect.
import { useIsomorphicLayoutEffect } from '@wt/hooks';
function Tooltip({ targetRef }) {
useIsomorphicLayoutEffect(() => {
// Measure DOM before paint
const rect = targetRef.current.getBoundingClientRect();
positionTooltip(rect);
}, [targetRef]);
return <div className="tooltip">Tip</div>;
}
Types
The package exports the following types:
type DeviceType = 'mobile' | 'tablet' | 'desktop';
interface DeviceInfo {
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
type: DeviceType;
}
Development
yarn install # Install dependencies
yarn test # Run tests (vitest)
yarn build # Compile TypeScript