# react-utils/hooks

React hooks for async effects, resize observation, drag interaction, viewport size, URL params, and stable resource references.

```typescript
import {
    useAsyncEffect,
    useDeps,
    useElementResizeEffect,
    useUpdatingRef,
    useMemoObjectShallow,
    useMemoObjectDeep,
    useCallbackOnce,
    useDrag,
    useClientSize,
    useSearchParams,
} from '@yaoc1996/js-tools/react-utils'
```

---

## Effect hooks

---

### `useDeps(deps)`

Converts a dependency array into a stable integer that increments whenever any dep changes. Use this to drive `useEffect`/`useCallback`/etc. with a single numeric dep instead of a raw array, eliminating the need for `// eslint-disable react-hooks/exhaustive-deps` comments.

#### Parameters

| Name | Type | Description |
|------|------|-------------|
| `deps` | `DependencyList` | Dependency array to track. |

#### Returns

`number` — starts at `1` on the first render and increments by `1` each time any element of `deps` changes (by `===` comparison).

#### Example

```typescript
const depsSeq = useDeps([id, filter])

useEffect(() => {
    // runs whenever id or filter changes
    fetchData(id, filter)
// eslint-disable-next-line — intentional: depsSeq encodes id + filter
}, [depsSeq])
```

#### Notes

- Ref mutation during render — same pattern used internally by `useAsyncEffect`.
- Empty array (`[]`) returns `1` forever after the first render.
- All comparisons use `===` (same as React's default dep comparison).

---

### `useAsyncEffect(getEffect, deps?, useEffectCaller?)`

An async-capable `useEffect` with built-in cancellation via `AbortSignal` and sequential execution — each run waits for the previous cleanup to complete before starting the next.

#### Parameters

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `getEffect` | `() => AsyncEffect` | — | Factory that returns the async effect to run. Called fresh on each execution via a ref, so it is never stale. |
| `deps` | `DependencyList \| undefined` | `undefined` | Dependency array. `undefined` re-runs on every render; `[]` runs once on mount. |
| `useEffectCaller` | `typeof useEffect \| typeof useLayoutEffect \| typeof useInsertionEffect` | `useEffect` | Override to run inside `useLayoutEffect` or `useInsertionEffect`. |

#### `AsyncEffect` signature

```typescript
type AsyncEffect = (signal: AbortSignal) => Promise<AsyncDestructor | void>
type AsyncDestructor = () => Promise<void | undefined | null>
```

The `signal` is aborted when the effect is cleaned up (component unmounts or deps change). The optional returned `AsyncDestructor` is awaited before the next effect run begins.

#### Example

```typescript
useAsyncEffect(() => async (signal) => {
    const data = await fetch('/api/data', { signal }).then(r => r.json())
    if (!signal.aborted) setData(data)

    return async () => {
        // optional async cleanup
    }
}, [id])
```

#### Notes

- Effects are sequenced via a promise chain: rapid dep changes queue cleanups and runs in order, with stale entries skipped.
- `getEffect` is always called fresh (via `useUpdatingRef`) — no stale closure risk.
- Requires no `// eslint-disable react-hooks/exhaustive-deps` in the body.

---

### `useElementResizeEffect(effect, divRef, options?)`

Calls `effect(width, height)` whenever the referenced element is resized, using a `ResizeObserver` under the hood. The effect callback is always fresh (via `useUpdatingRef`) — changing `effect` does not recreate the observer.

#### Parameters

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `effect` | `ResizeEffect` | — | Called with the element's `width` and `height` on each resize. May return a destructor that is called before the next resize. |
| `divRef` | `RefObject<HTMLElement \| SVGElement \| null>` | — | Ref to the element to observe. The observer is created when `divRef.current` is non-null. |
| `options` | `ResizeOptions` | `undefined` | Optional `{ debounce?: number }`. Defaults to 66 ms debounce. |

#### `ResizeEffect` signature

```typescript
type ResizeEffect = (width: number, height: number) => Destructor | void
type Destructor = () => void
```

#### Example

```typescript
const divRef = useRef<HTMLDivElement>(null)

useElementResizeEffect((width, height) => {
    chart.resize(width, height)
    return () => chart.cleanup()
}, divRef)
```

#### Notes

- The observer is only recreated when `divRef` or `debounce` changes — not when `effect` changes.
- The destructor returned by `effect` is called before the next invocation, not on unmount.

---

## Resource hooks

---

### `useUpdatingRef<T>(value)`

Returns a `MutableRefObject` whose `.current` is always set to the latest `value` during render. Use this to read a reactive value inside an effect without adding it to the effect's dep array.

#### Parameters

| Name | Type | Description |
|------|------|-------------|
| `value` | `T` | The value to keep in sync with the ref. |

#### Returns

`MutableRefObject<T>` — the same ref object across all renders; `.current` is updated synchronously before effects run.

#### Example

```typescript
const onChangeRef = useUpdatingRef(onChange)

useEffect(() => {
    const sub = subscribe(id, (...args) => onChangeRef.current(...args))
    return () => sub.unsubscribe()
}, [id]) // onChange intentionally omitted — always fresh via ref
```

#### Notes

- This is the React-recommended production workaround for "I need the latest value inside an effect without re-running it." (`useEffectEvent` is still experimental as of React 19.2.)
- Writing to a ref during render is explicitly endorsed by React docs for this pattern.

---

### `useMemoObjectShallow<T>(obj)`

Returns a stable object reference that only changes when the object is no longer shallowly equal to the previous value. Prevents unnecessary re-renders when a parent passes a new object literal with the same shape and values.

#### Parameters

| Name | Type | Description |
|------|------|-------------|
| `obj` | `T extends object \| undefined \| null` | The object to stabilise. |

#### Returns

`T` — the previous reference if shallowly equal to `obj`, otherwise `obj` itself.

#### Example

```typescript
// Parent passes a new literal every render, but values are the same:
const stableOptions = useMemoObjectShallow({ color: 'red', size: 12 })
// stableOptions reference is unchanged → child does not re-render
```

#### Notes

- Unlike `useMemo`, this is a hard equality guard — React cannot drop the memoisation under memory pressure.
- `null` and `undefined` are passed through as-is with reference equality.
- Shallow comparison checks own keys and values with `===`.

---

### `useMemoObjectDeep<T>(obj)`

Same as `useMemoObjectShallow` but uses `lodash-es/isEqual` for deep structural equality. Use when the object contains nested structures that should be compared by value.

#### Parameters

| Name | Type | Description |
|------|------|-------------|
| `obj` | `T extends object \| undefined \| null` | The object to stabilise. |

#### Returns

`T` — the previous reference if deeply equal to `obj`, otherwise `obj` itself.

#### Example

```typescript
const stableFilters = useMemoObjectDeep({ range: [0, 100], tags: ['a', 'b'] })
```

#### Notes

- Deep equality can be expensive for large objects — prefer `useMemoObjectShallow` when top-level key/value comparison is sufficient.

---

### `useCallbackOnce<Args>(effect)`

Wraps a callback so it fires at most once per `effect` identity. Subsequent calls to the returned wrapper are no-ops. Resets automatically when `effect` changes identity.

#### Parameters

| Name | Type | Description |
|------|------|-------------|
| `effect` | `(...args: Args) => unknown` | The function to call at most once. |

#### Returns

`(...args: Args) => unknown | undefined` — a stable wrapper that delegates to `effect` on the first call only.

#### Example

```typescript
const initOnce = useCallbackOnce(() => {
    analytics.track('first-render')
})

// Safe to call from multiple places — only fires once:
<button onClick={initOnce}>Start</button>
```

#### Notes

- The wrapper reference is stable as long as `effect` identity is unchanged (memoised with `useMemo`).
- If `effect` is a new arrow function each render, the "once" guard resets — pass a stable ref or memoised callback.
- Returns `undefined` on all calls after the first.

---

## Interaction hooks

---

### `useDrag(elemRef, effect, options?)`

Tracks mouse drag gestures on a referenced element. Attaches `mousedown` on the element and `mousemove`/`mouseup` on `window`, computing coordinates relative to the element's bounding rect. The `effect` callback fires when the drag ends with a non-zero displacement.

#### Parameters

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `elemRef` | `RefObject<HTMLElement \| null>` | — | Ref to the element to observe for drag start. |
| `effect` | `DragEffect` | — | Called on drag end with `(start, end)` in element-local coordinates. Not called if start equals end. |
| `options` | `DragOptions` | `{}` | Optional settings. |

#### `DragOptions`

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `minWait` | `number` | `0` | Throttle interval in ms for `mousemove` updates. |

#### Returns

`[isDragging, dragStart, dragEnd]` — a tuple of reactive state:

| Index | Type | Description |
|-------|------|-------------|
| `0` | `boolean` | `true` while a drag is in progress. |
| `1` | `[number, number] \| null` | Element-local coordinates where the drag started, or `null` when not dragging. |
| `2` | `[number, number] \| null` | Current element-local drag position (updated on each `mousemove`), or `null` when not dragging. |

#### Example

```typescript
const containerRef = useRef<HTMLDivElement>(null)

const [isDragging, dragStart, dragEnd] = useDrag(
    containerRef,
    (start, end) => {
        console.log('drag from', start, 'to', end)
    },
    { minWait: 16 }
)
```

#### Notes

- Coordinates are clamped to `[0, width]` × `[0, height]` of the element's bounding rect.
- `effect` is captured via a ref — changing the callback between renders does not recreate the drag listeners.
- Listeners are removed on unmount; if a drag is mid-flight, state is reset to `null`.

---

## Viewport & URL hooks

---

### `useClientSize(w?, h?)`

Returns the current window viewport size as `[width, height]`, updating whenever the browser window is resized. Debounced at ~33 ms.

#### Parameters

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `w` | `number` | `0` | Initial width returned before the first resize measurement. |
| `h` | `number` | `0` | Initial height returned before the first resize measurement. |

#### Returns

`readonly [number, number]` — `[width, height]` in pixels.

#### Example

```typescript
const [width, height] = useClientSize()
```

#### Notes

- Values are sourced from `window.innerWidth` / `window.innerHeight`.
- Uses `lodash-es/debounce` at 33 ms; the pending callback is cancelled on unmount.
- The effect runs once on mount and the listener is removed on unmount.

---

### `useSearchParams()`

Returns `[searchParams, setSearchParams]` — a reactive `URLSearchParams` that updates whenever the URL changes, and a setter that updates the URL via `history.replaceState`.

#### Returns

`readonly [URLSearchParams, setSearchParams]`

The setter signature:
```typescript
setSearchParams(
    params: URLSearchParamsLike | ((p: URLSearchParams) => URLSearchParamsLike),
    options?: { removeParams?: string[] }
): void
```

Existing params not present in `params` are preserved. Pass `removeParams` to explicitly delete keys.

#### Example

```typescript
const [searchParams, setSearchParams] = useSearchParams()

const id = searchParams.get('id')

setSearchParams({ id: 'new-id' })
setSearchParams(p => ({ tab: p.get('tab') ?? 'default' }), { removeParams: ['modal'] })
```

#### Notes

- URL changes are polled at 100 ms intervals via `setInterval`; the interval is shared across all hook instances and cleared when the last consumer unmounts.
- Uses `history.replaceState` (no new history entry).

---

## Types

All types are exported from `@yaoc1996/js-tools/react-utils`.

```typescript
type NoReturnType = void | undefined | null
type Destructor = () => NoReturnType
type AsyncDestructor = () => Promise<NoReturnType>

type Effect = () => Destructor | NoReturnType
type EffectWithArgs<Args extends unknown[] = []> = (...args: Args) => ReturnType<Effect>

type AsyncEffect = (signal: AbortSignal) => Promise<AsyncDestructor | NoReturnType>
type AsyncEffectWithArgs<Args extends unknown[] = []> = (...args: Args) => ReturnType<AsyncEffect>

type ResizeEffect = (width: number, height: number) => Destructor | NoReturnType

type DragOptions = {
    minWait?: number
}
type DragEffect = (start: [number, number], end: [number, number]) => void
```
