---
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.
