@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. enabledguard — queries stay disabled (and don't callfetchData) until a client is available.isPending→isLoading—useKiotaMutation's result addsisLoading(TanStack v5'suseQuery/useInfiniteQueryalready exposeisLoadingnatively).ApiErrornormalization — on a 4xx response whose error model carries adetailstring (a Kiota problem-details error),error.messageis replaced withdetailso 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).