# @saif/platform-react

React bindings for `@saif/platform`: platform/auth context, feature flags, and a generic
TanStack Query layer for Kiota-generated API clients.

## Platform setup

Wrap your app once in `PlatformProvider`. It sets up the `Platform`/`AuthProvider` context and
mounts a pre-tuned `QueryClient` (sane caching window, no retries on 4xx `ApiError`s, no
refetch-on-window-focus, React Query Devtools in dev).

```tsx
import { PlatformProvider } from '@saif/platform-react';

<PlatformProvider>
  <App />
</PlatformProvider>;
```

Escape hatches, for tests/SSR or to tune the cache without replacing it entirely:

```tsx
<PlatformProvider queryClient={myQueryClient}>...</PlatformProvider>

<PlatformProvider queryClientOverrides={{ defaultOptions: { queries: { staleTime: 30_000 } } }}>
  ...
</PlatformProvider>
```

If you need the cache provider without the rest of `PlatformProvider` (Storybook, a narrower
test), use `PlatformQueryClientProvider` directly — it accepts the same two props.

`AuthProvider` is also exported on its own, but it now calls TanStack Query's `useQueryClient`
internally (to clear the cache on logout), so mounting it outside `PlatformProvider` or
`PlatformQueryClientProvider` throws. If you're composing it standalone, wrap it in a
`QueryClientProvider` yourself.

## Kiota + TanStack Query hooks

Every golden-path frontend ships a Kiota-generated client and needs caching, retries, dedup, and
infinite scrolling. `createKiotaHooks` binds a set of TanStack Query hooks to your concrete
client type once, so the rest of the app never imports TanStack directly and never re-implements
client injection, the `enabled` guard, or `ApiError` handling.

### 1. Bind the hooks to your generated client (once)

```ts
// src/api/kiota.ts
import { createKiotaHooks } from '@saif/platform-react';
import type { BackendClient } from '@api/backendClient';

export const {
  KiotaClientProvider,
  useKiotaClient,
  useKiotaQuery,
  useKiotaInfiniteQuery,
  useKiotaMutation,
} = createKiotaHooks<BackendClient>();
```

### 2. Supply the client instance

`KiotaClientProvider` nests inside `PlatformProvider` — it needs your concrete client instance,
which is app-specific, so it can't be folded into the platform provider itself.

```tsx
import { KiotaClientProvider } from '@api/kiota';
import { PlatformProvider } from '@saif/platform-react';

<PlatformProvider>
  <KiotaClientProvider client={backendClient}>{children}</KiotaClientProvider>
</PlatformProvider>;
```

### 3. Write thin, domain-specific wrappers

```ts
// src/hooks/useSidenavQuery.ts
import { useKiotaQuery } from '@api/kiota';

export const useSidenavQuery = () =>
  useKiotaQuery(['navigation.sidenav'], (client) => client.navigation.get());
```

```tsx
function Sidenav() {
  const { data, isLoading, error } = useSidenavQuery();

  if (isLoading) return <Spinner />;
  if (error) return <ErrorBanner message={error.message} />;
  return <nav>{/* render data */}</nav>;
}
```

Mutations follow the same shape — `mutationFn` receives the injected client plus your variables:

```ts
export const useMarkNotificationReadMutation = () =>
  useKiotaMutation({
    mutationFn: (client, body: { id: string; isRead: boolean }) =>
      client.userNotifications.byNotificationId(body.id).patch({ isRead: body.isRead }),
  });
```

Infinite lists follow the standard TanStack shape (`initialPageParam` + `getNextPageParam`
required, page data available at `data.pages`):

```ts
export const useAuditLogQuery = () =>
  useKiotaInfiniteQuery(
    ['audit-log'],
    (client, pageParam: string | undefined) => client.auditLog.get({ queryParameters: { cursor: pageParam } }),
    {
      initialPageParam: undefined,
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    },
  );
```

### Sending the active locale as a header

Client construction (auth provider, base URL, interceptors) is app-specific and stays in your own
`BackendProvider` — but the boilerplate of merging a header onto every outgoing request is generic.
`withLocaleHeader` wraps a Kiota `HttpClient` fetch function to send `Accept-Language`; the locale
value/source (i18next, a route segment, a cookie, ...) is still up to you:

```ts
import { withLocaleHeader } from '@saif/platform-react';

const httpClient = new HttpClient(
  withLocaleHeader(fetchWithUnauthorizedRedirect, locale),
);
```

Prefer a `() => string | undefined` getter over a plain string if the client itself is built in a
`useMemo`/`useState` you don't want to re-run on every locale change (which is the common case —
Kiota clients are Proxies, and handing a new one to a component as a prop on every locale switch
makes React's dev-mode "why did this re-render" diffing try to introspect it, which Kiota's proxy
doesn't support and throws on). Read the locale from a ref instead of closing over it, so the
client's identity stays stable and only the header value changes per-request:

```tsx
const localeRef = useRef(locale);
useEffect(() => {
  localeRef.current = locale;
}, [locale]);

const client = useMemo(() => {
  const httpClient = new HttpClient(
    withLocaleHeader(fetchWithUnauthorizedRedirect, () => localeRef.current),
  );
  return createBackendClient(new FetchRequestAdapter(auth, undefined, undefined, httpClient));
}, [/* no locale dependency */]);
```

### Redirecting on a 401

`withUnauthorizedRedirect` wraps a fetch function to cancel and clear the query cache on a 401
(so no stale authenticated data lingers) and guards against firing more than one redirect when
several requests 401 in parallel. What "unauthorized" actually navigates to is still yours:

```ts
import { withUnauthorizedRedirect } from '@saif/platform-react';

const fetchWithUnauthorizedRedirect = withUnauthorizedRedirect(fetch, {
  queryClient, // the same QueryClient instance passed to PlatformProvider
  onUnauthorized: () => {
    window.location.href = buildUrlWithErrorCode('session_expired');
  },
  isRedirectSuppressed: isOnErrorPage,
});
```

Compose the two together in your `BackendProvider`:

```ts
const httpClient = new HttpClient(
  withLocaleHeader(
    withUnauthorizedRedirect(fetch, { queryClient, onUnauthorized, isRedirectSuppressed }),
    locale,
  ),
);
```

Passing `queryClient` here only works if your app constructs its own `QueryClient` and hands it
to `PlatformProvider` via the `queryClient` prop — otherwise this plain function has no way to
reach the instance `PlatformProvider` would otherwise create internally.

### What the library normalizes for you

- **Client injection** — every hook reads the client from `KiotaClientProvider`'s context; you
  never pass it manually.
- **`enabled` guard** — queries stay disabled (and don't call `fetchData`) until a client is
  available.
- **`isPending` → `isLoading`** — `useKiotaMutation`'s result adds `isLoading` (TanStack v5's
  `useQuery`/`useInfiniteQuery` already expose `isLoading` natively).
- **`ApiError` normalization** — on a 4xx response whose error model carries a `detail` string
  (a Kiota problem-details error), `error.message` is replaced with `detail` so every call site
  gets a useful message without checking for it itself.

### What stays app-specific

- The concrete client type, supplied as `createKiotaHooks<BackendClient>()`'s type parameter.
- Anything you want appended to every query key (e.g. the active locale) — do it in your own
  wrapper hooks, not in the shared client setup.
- The domain hooks themselves (`useSidenavQuery`, mutations, etc.) and the client instance
  construction (auth provider, base URL, request adapter).

---

[View source on GitHub](https://github.com/saif-corp/forge/blob/main/src/typescript/packages/saif-platform-react/README.md)
