Skip to content

@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 ApiErrors, no refetch-on-window-focus, React Query Devtools in dev).

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

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

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

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

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

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

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

3. Write thin, domain-specific wrappers

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

export const useSidenavQuery = () =>
  useKiotaQuery(['navigation.sidenav'], (client) => client.navigation.get());
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:

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):

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:

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:

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:

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:

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