UNPKG

@loke/ui

Version:
258 lines (198 loc) 9.12 kB
--- 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 DirectionProviderRTL 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 1619. 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.