@loke/ui
Version:
258 lines (198 loc) • 9.12 kB
Markdown
---
name: hooks
type: core
domain: composition
requires: [loke-ui]
description: >
Ten shared hooks for building custom components. useControllableState
(controlled/uncontrolled dual-mode with dev warnings on mode switch).
useCallbackRef (stable callback identity without stale closure risk).
useDirection/DirectionProvider (RTL support). useEscapeKeydown (capture-phase
escape). useId (React 16-19 compat). useSize (ResizeObserver border-box).
usePrevious (previous render value). useLayoutEffect (SSR-safe). useIsHydrated
(render gating for portals and browser-only UI). useIsDocumentHidden
(Page Visibility API).
references:
- references/hooks-reference.md
---
# Hooks
## Setup
The most common hook when building form-like custom components is `useControllableState`. It lets your component work in both controlled and uncontrolled modes with a single implementation.
```tsx
import { useControllableState } from "@loke/ui/use-controllable-state";
interface AccordionProps {
value?: string; // controlled
defaultValue?: string; // uncontrolled initial
onValueChange?: (value: string) => void;
children: React.ReactNode;
}
function Accordion({ value, defaultValue = "", onValueChange, children }: AccordionProps) {
const [openItem, setOpenItem] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange: onValueChange,
caller: "Accordion", // shown in dev warning if mode switches
});
return (
<div data-value={openItem}>
{children}
</div>
);
}
```
In controlled mode (`value` provided): `setOpenItem` calls `onChange` only, no internal state update.
In uncontrolled mode (`value` undefined): `setOpenItem` updates internal state and calls `onChange`.
## Core Patterns
### useCallbackRef — stable callbacks without stale closures
Pass callbacks as dependencies to `useEffect` or across asynchronous boundaries without triggering re-runs. The returned ref always calls the latest version of the callback.
```tsx
import { useCallbackRef } from "@loke/ui/use-callback-ref";
function usePointerTracker(onMove?: (x: number, y: number) => void) {
// Without useCallbackRef, adding onMove to deps causes listener re-registration
// every render. With it, the listener is stable.
const handleMove = useCallbackRef(onMove);
useEffect(() => {
const listener = (e: PointerEvent) => handleMove(e.clientX, e.clientY);
document.addEventListener("pointermove", listener);
return () => document.removeEventListener("pointermove", listener);
}, []); // no deps needed — handleMove is stable
}
```
The hook works by storing the latest callback in a ref, updated via `useEffect` after every render. The returned stable function closes over the ref, not the callback directly.
### useDirection and DirectionProvider — RTL support
`useDirection` reads from the nearest `DirectionProvider`. Falls back to a local `dir` prop, then to `"ltr"`. Use `DirectionProvider` at the app root to set global direction.
```tsx
import { DirectionProvider, useDirection } from "@loke/ui/use-direction";
// At app root
function App() {
return (
<DirectionProvider dir="rtl">
<MyApp />
</DirectionProvider>
);
}
// In a component that needs to know direction
function FloatingContent({ dir }: { dir?: "ltr" | "rtl" }) {
const direction = useDirection(dir); // local prop overrides provider
return <div dir={direction}>...</div>;
}
```
### useId — cross-React-version stable IDs
Generates a stable ID for accessibility attributes (`aria-labelledby`, `htmlFor`, etc.) across React 16–19. Accepts an optional deterministic ID to use instead.
```tsx
import { useId } from "@loke/ui/use-id";
function FormField({ id: idProp, label, children }: {
id?: string;
label: string;
children: React.ReactElement;
}) {
const id = useId(idProp); // "loke-:r0:" in React 18+, "loke-0" in React 16/17
return (
<div>
<label htmlFor={id}>{label}</label>
{React.cloneElement(children, { id })}
</div>
);
}
```
### useSize — element dimension tracking
Returns `{ width, height }` using `ResizeObserver` with `box: "border-box"`. Returns `undefined` until the element mounts.
```tsx
import { useSize } from "@loke/ui/use-size";
import { useState } from "react";
function ResponsivePanel() {
const [element, setElement] = useState<HTMLDivElement | null>(null);
const size = useSize(element);
return (
<div ref={setElement}>
{size && <span>{size.width}×{size.height}</span>}
</div>
);
}
```
## Key Insight: useIsHydrated vs useLayoutEffect
These two hooks solve different problems and must not be mixed up.
**`useIsHydrated`** — gates **render output**. Use it when the component should render different JSX on the server vs. after hydration (portals, browser-only APIs, `document`-dependent UI).
```tsx
import { useIsHydrated } from "@loke/ui/use-is-hydrated";
import { Portal } from "@loke/ui/portal";
function ConditionalPortal({ children }: { children: React.ReactNode }) {
const hydrated = useIsHydrated();
// Do NOT render portals until hydrated — avoids SSR/client mismatch
if (!hydrated) return null;
return <Portal>{children}</Portal>;
}
```
**`useLayoutEffect`** (SSR-safe) — gates **post-render side effects**. Use it when you need synchronous DOM measurement or mutation after render, but you're in an environment that may SSR.
```tsx
import { useLayoutEffect } from "@loke/ui/use-layout-effect";
function SizeAware({ onSize }: { onSize: (rect: DOMRect) => void }) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (ref.current) {
onSize(ref.current.getBoundingClientRect()); // safe — noop on server
}
}, [onSize]);
return <div ref={ref} />;
}
```
The SSR-safe `useLayoutEffect` is a **noop** on the server (no `globalThis.document`). `useIsHydrated` returns `false` on the server and `true` after hydration via `useSyncExternalStore`. They are not interchangeable.
## Common Mistakes
### 1. Switching controlled/uncontrolled mode at runtime
`useControllableState` emits a dev warning and behaves unpredictably if `prop` changes between `undefined` and a defined value after mount. Decide at component design time which mode you support.
```tsx
// WRONG — prop starts undefined (uncontrolled), then gets a value (controlled)
const [value, setValue] = useControllableState({
prop: someCondition ? externalValue : undefined,
defaultProp: "",
});
// CORRECT — if the component must be controlled, always pass a value
const [value, setValue] = useControllableState({
prop: externalValue ?? defaultValue,
defaultProp: defaultValue,
});
```
Source: `controllable-state.tsx` — dev `useEffect` checks `isControlledRef.current !== isControlled` and warns.
### 2. Using React's useLayoutEffect directly in SSR-rendered components
React's `useLayoutEffect` logs a warning on every SSR render. The library's SSR-safe version suppresses this by substituting a noop when `globalThis.document` is absent.
```tsx
// WRONG — logs warning on SSR: "useLayoutEffect does nothing on the server"
import { useLayoutEffect } from "react";
// CORRECT — silent noop on server, real useLayoutEffect on client
import { useLayoutEffect } from "@loke/ui/use-layout-effect";
```
Source: `layout-effect.tsx` — `globalThis?.document ? useReactLayoutEffect : () => {}`.
### 3. RTL layout without DirectionProvider
`useDirection()` without a provider returns `"ltr"` regardless of the page's actual `dir` attribute. Components that calculate directional offsets (Popper, roving focus) will produce incorrect layouts in RTL apps.
```tsx
// WRONG — always "ltr" even if <html dir="rtl">
function App() {
return <MyApp />;
}
// CORRECT — reads document direction and propagates via context
function App() {
return (
<DirectionProvider dir={document.documentElement.dir as "ltr" | "rtl" || "ltr"}>
<MyApp />
</DirectionProvider>
);
}
```
Source: `direction.tsx` — `useDirection` reads `DirectionContext`, which is `undefined` without a provider, falling back to the `localDir` arg then `"ltr"`.
### 4. Using useIsHydrated to gate post-render effects
`useIsHydrated` returns `false` during SSR but `true` on the first client render (after hydration). It gates **render output**, not effects. Using it to delay `useEffect` or `useLayoutEffect` is incorrect and introduces a hydration mismatch if the server and client render different structures.
```tsx
// WRONG — gating an effect with hydration state
const hydrated = useIsHydrated();
useEffect(() => {
if (hydrated) {
measureSomething();
}
}, [hydrated]);
// CORRECT — effects already only run on the client; use SSR-safe useLayoutEffect
import { useLayoutEffect } from "@loke/ui/use-layout-effect";
useLayoutEffect(() => {
measureSomething(); // noop on server, runs after paint on client
}, []);
```
Source: `is-hydrated.ts` — uses `useSyncExternalStore` with server snapshot `() => false` and client snapshot `() => true`, so it is specifically for render-path branching.