# Router hooks

Framework-agnostic navigation primitives for `@djangocfg/ui-core`. Built on plain browser APIs (`window.history`, `window.location`, `URLSearchParams`, `popstate`). No `next/*`, no `react-router`, no third-party deps.

## Surface

| Hook | Purpose |
| --- | --- |
| `useLocation` | Reactive snapshot of `window.location` (`pathname`, `search`, `hash`, `href`). |
| `useNavigate` | Programmatic navigation: `navigate`, `navigateExternal`, `push`, `replace`, `back`, `forward`. |
| `useQueryParams` | Read/write `?key=value` URL state with typed coercion (`get`, `getNumber`, `getBoolean`, `set`, `remove`, `clear`). |
| `useBackOrFallback` | Smart "back" that falls back to a route when there's no in-app history. |
| `useUrlBuilder` | Pure URL/querystring assembly: `build`, `withCurrentParams`. |
| `useSmartLink` | Click + keyboard handlers that turn any element into a proper link (cmd-click, middle-click, Enter, Space). |
| `useIsActive` | `boolean` for "current pathname matches this href" — for nav-item highlighting. |
| `useQueryState` | Typed `useState`-style hook bound to ONE URL key (with parsers + `clearOnDefault`). |
| `useLocationProperty` | Subscribe to ONE derived field of `window.location` (avoids re-renders on unrelated fields). |
| `useRouter` | Convenience facade composing the above. |
| `RouterAdapterProvider` | Swap the navigation backend (e.g. Next.js's router). |
| `parseAsString` / `parseAsInteger` / `parseAsFloat` / `parseAsBoolean` / `parseAsIsoDate` / `parseAsStringEnum` / `parseAsArrayOf` / `parseAsJson` | Parser builders for `useQueryState`. Each has `.withDefault(value)`. |

## Decomposition rationale

Each atomic hook subscribes to exactly what it needs. A component that only calls `navigate(...)` shouldn't re-render every time the querystring changes — `useNavigate` doesn't subscribe to location, so it doesn't. Same for `useUrlBuilder` (only re-renders on `search` change), `useQueryParams` (same), and so on.

`useRouter` exists for convenience and ergonomic familiarity. Use it when you want everything in one return; use the atomic hooks for fewer re-renders and better tree-shaking.

## Adapter pattern

Default behavior uses `window.history.pushState` + `window.location` and works in any browser (Wails / Electron / Vite / CRA — nothing to mount, it just works).

For Next.js — mount both adapters once near the root. They bridge router hooks to `next/navigation` and `<Link>` to `next/link` so server components, route loaders, and prefetch fire correctly:

```tsx
// app/[locale]/layout.tsx (or wherever your client provider stack lives)
import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';

<I18nProvider locale={locale} messages={messages}>
  <NextRouterAdapter>
    <NextLinkProvider>
      {children}
    </NextLinkProvider>
  </NextRouterAdapter>
</I18nProvider>
```

> Using `@djangocfg/layouts`? `BaseApp` already mounts both adapters — you don't
> need this manual wiring.

`next` is an **optional peer dependency** — the package never imports from `next/*` from the main entry. The Next adapter lives behind the `/adapters/nextjs` sub-path entry, so non-Next consumers don't pull `next` into their bundle.

For other routers (TanStack Router, wouter, Remix, custom transports) — write a ~20-line custom adapter:

```tsx
'use client';

import { useMemo } from 'react';
import { RouterAdapterProvider, type RouterAdapter } from '@djangocfg/ui-core/hooks';

export function MyRouterAdapter({ children }: { children: React.ReactNode }) {
  const myRouter = useMyRouter();
  const adapter = useMemo<RouterAdapter>(() => ({
    push:    (url) => myRouter.push(url),
    replace: (url) => myRouter.replace(url),
    back:    () => myRouter.back(),
    forward: () => myRouter.forward(),
    getLocation: () => ({
      pathname: window.location.pathname,
      search:   window.location.search,
      hash:     window.location.hash,
    }),
  }), [myRouter]);

  return <RouterAdapterProvider value={adapter}>{children}</RouterAdapterProvider>;
}
```

**Adapter contract gotcha:** custom `push` / `replace` implementations should ultimately route through `window.history.pushState` (Next does, so does react-router). If yours doesn't, dispatch `'djc:navigate'` after each navigation manually so `useLocation` re-reads.

## `useQueryState` (typed URL state)

Bound to one query key with a typed parser. Inspired by `nuqs` but framework-agnostic via the same adapter context.

```tsx
import {
  useQueryState,
  parseAsInteger,
  parseAsStringEnum,
} from '@djangocfg/ui-core/hooks';

const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [tab,  setTab]  = useQueryState('tab',  parseAsStringEnum(['list', 'grid']).withDefault('list'));

setPage((prev) => prev + 1); // functional updater, just like useState
setPage(null);               // clears the key from the URL
```

`clearOnDefault` (default `true`) drops the key from the URL when the value equals the parser default — keeps URLs short. Pass `{ clearOnDefault: false }` to keep explicit `?page=1` for shareable links.

Parsers: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsIsoDate`, `parseAsStringEnum([...])`, `parseAsArrayOf(item)`, `parseAsJson<T>()`. Each has `.withDefault(value)`. Build your own by satisfying the `QueryParser<T>` interface — it's three functions (`parse`, `serialize`, `eq`).

## How `useLocation` knows about `pushState`

Browsers don't fire `popstate` for programmatic `pushState` / `replaceState`. We monkey-patch both methods once (idempotent, module-level guard) on first mount and dispatch a custom `'djc:navigate'` event after each call. Anyone calling history APIs anywhere in the page will trigger an update — including the consumer's own router, third-party scripts, and our default adapter.

## SSR

- All hooks return safe defaults on the server (`pathname: '/'`, etc.).
- `useSyncExternalStore`'s `getServerSnapshot` is wired up correctly.
- Mutating methods (`push`, `replace`, `navigate`, `navigateExternal`) no-op when `window` is undefined.
- No hydration mismatches — first client render reads real `window.location` after mount via `useSyncExternalStore`'s `getSnapshot`.

## Trade-offs vs. `next/navigation`

| Concern | `next/navigation` | This library |
| --- | --- | --- |
| Server components fire | yes (built-in) | only if you mount the Next adapter |
| Pending state (`useTransition`) | bundled in | wrap calls yourself |
| Locale-prefix handling | yes | no — wrap if needed |
| Route matching / dynamic segments | yes | no — out of scope |
| Works outside Next | no | yes — anywhere React runs |
| `<Link>` component | yes | yes — `<Link>` / `<ButtonLink>` in `@djangocfg/ui-core/components` (Next-aware via `NextLinkProvider`) |

Out of scope: locale prefixes, route matching, dynamic segments, transitions. Add them in consumer code or in higher-level packages.
