# Hooks

> Small client hooks used across apps; useDebounce and useDebouncedCallback for search and autosave, useMediaQuery and useIsMobile for responsive logic, and useInfiniteScroll for loading the next page near the end of a list.

Source: https://docs.nasaqui.com/components/hooks

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/hooks.json
```

Plain React hooks with no dependencies. They are safe to render on the server.

## When to use

- `useDebounce`: filter or search after typing stops.
- `useDebouncedCallback`: autosave, resize handlers, anything that should run once after a burst.
- `useMediaQuery` / `useIsMobile`: when the logic, not only the CSS, changes with the screen.
- `useInfiniteScroll`: feeds and long lists that load page by page.

## When not to use

- Layout changes only: use Tailwind breakpoints in CSS, not `useIsMobile`.
- Very long lists: virtualise the rows; infinite scroll alone keeps every row in the DOM.

## Import

```tsx
import { useDebounce, useDebouncedCallback, useMediaQuery, useIsMobile, useInfiniteScroll } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { Button, Input, useDebounce, useInfiniteScroll } from "@fadymondy/nasaq/web";
import { useState } from "react";

declare function useSearch(q: string): { rows: string[]; hasMore: boolean; loading: boolean; loadMore(): void };

export function Search() {
  const [q, setQ] = useState("");
  const debounced = useDebounce(q, 300);
  const { rows, hasMore, loading, loadMore } = useSearch(debounced);
  const sentinel = useInfiniteScroll({ onLoadMore: loadMore, hasMore, loading });
  return (
    <>
      <Input value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
      <ul>{rows.map((r) => <li key={r}>{r}</li>)}</ul>
      {hasMore ? <Button ref={sentinel} onClick={loadMore}>Load more</Button> : null}
    </>
  );
}
```

## API

| Hook | Returns | Notes |
| --- | --- | --- |
| `useDebounce(value, delay = 300)` | the value | Updates `delay` ms after the last change. |
| `useDebouncedCallback(fn, delay = 300)` | a stable function with `.cancel()` | Always calls the latest `fn`. Cancelled on unmount. |
| `useMediaQuery(query)` | `boolean` | `false` on the server and the first client render. |
| `useIsMobile(breakpoint = 768)` | `boolean` | True below the breakpoint (Tailwind `md`). |
| `useInfiniteScroll(options)` | a ref callback | Put it on an element after the last item. |

**UseInfiniteScrollOptions**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `onLoadMore` | `() => void` | required | Not called again while `loading`. |
| `hasMore` | `boolean` | required | Stops observing when false. |
| `loading` | `boolean` | `false` | |
| `rootMargin` | `string` | `"240px"` | Start loading this far before the sentinel shows. |
| `root` | `Element \| null` | the viewport | The scrolling element, when it is not the page. |
| `disabled` | `boolean` | `false` | |

## Accessibility

- Infinite scroll is invisible to keyboard and screen-reader users: keep a "Load more" button as the sentinel,
  and announce new rows (`aria-live`) when it helps.
- `useMediaQuery("(prefers-reduced-motion: reduce)")` lets JavaScript animations respect the setting.

## RTL & i18n

- No text or direction.

## Styling & tokens

- No styles.

## Do / Don't

- Do debounce the value you query with, not the input's own value.
- Don't render different content on the server and client from `useIsMobile`; it is false on the server.

## Related

- [`DataState`](https://docs.nasaqui.com/components/data-state)
- [`Table`](https://docs.nasaqui.com/components/table)

## Lab

https://docs.nasaqui.com/?path=/docs/components-utilities-hooks--docs

## Code

### React

```tsx
import { Button, Input, useDebounce, useInfiniteScroll } from "@fadymondy/nasaq/web";
import { useState } from "react";

declare function useSearch(q: string): { rows: string[]; hasMore: boolean; loading: boolean; loadMore(): void };

export function Search() {
  const [q, setQ] = useState("");
  const debounced = useDebounce(q, 300);
  const { rows, hasMore, loading, loadMore } = useSearch(debounced);
  const sentinel = useInfiniteScroll({ onLoadMore: loadMore, hasMore, loading });
  return (
    <>
      <Input value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
      <ul>{rows.map((r) => <li key={r}>{r}</li>)}</ul>
      {hasMore ? <Button ref={sentinel} onClick={loadMore}>Load more</Button> : null}
    </>
  );
}
```

### shadcn

```tsx
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/field";
import { useDebounce, useInfiniteScroll } from "@/components/ui/hooks";
import { useState } from "react";

declare function useSearch(q: string): { rows: string[]; hasMore: boolean; loading: boolean; loadMore(): void };

export function Search() {
  const [q, setQ] = useState("");
  const debounced = useDebounce(q, 300);
  const { rows, hasMore, loading, loadMore } = useSearch(debounced);
  const sentinel = useInfiniteScroll({ onLoadMore: loadMore, hasMore, loading });
  return (
    <>
      <Input value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
      <ul>{rows.map((r) => <li key={r}>{r}</li>)}</ul>
      {hasMore ? <Button ref={sentinel} onClick={loadMore}>Load more</Button> : null}
    </>
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { NqButton, useDebounce } from "@fadymondy/nasaq/vue";
import { ref } from "vue";

const q = ref("");
const debounced = useDebounce(q, 300);
</script>

<template>
  <div class="flex flex-col gap-2">
    <input v-model="q" aria-label="Search" class="h-control rounded-control border border-border bg-background px-3" />
    <p class="text-body-sm text-muted-foreground">Searching for: {{ debounced }}</p>
    <NqButton @click="q = ''">Clear</NqButton>
  </div>
</template>
```
