-
Notifications
You must be signed in to change notification settings - Fork 0
Framework Recipes
The client is framework-agnostic. These are integration patterns, not requirements.
Works out of the box — failures reject, which is exactly what the library expects. No per-call flags needed.
// lib/api.ts
export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL });
// features/users/queries.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@mrzr/api-client";
import { api } from "@/lib/api";
export const userKeys = {
all: ["users"] as const,
lists: () => [...userKeys.all, "list"] as const,
list: (page: number) => [...userKeys.lists(), page] as const,
detail: (id: number) => [...userKeys.all, "detail", id] as const,
};
export function useUsers(page = 1) {
return useQuery({
queryKey: userKeys.list(page),
// `signal` wires react-query cancellation straight through to fetch
queryFn: ({ signal }) => api.get<User[]>("/users", { params: { page }, signal }),
select: (res) => res.data ?? [],
});
}
export function useUser(id: number) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: ({ signal }) =>
api.get<User>("/users/{id}", { addTemplateToUrl: { id }, signal }),
select: (res) => res.data,
enabled: !!id,
retry: (count, error) => {
// Never retry client errors; ApiError carries the status code.
if (error instanceof ApiError && error.statusCode < 500) return false;
return count < 3;
},
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateUser) => api.post<User>("/users", input),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.lists() }),
onError: (e) => {
if (e instanceof ApiError && e.statusCode === 422) setFieldErrors(e.errors ?? {});
},
});
}const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (count, error) =>
error instanceof ApiError && error.statusCode < 500 ? false : count < 3,
staleTime: 30_000,
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error instanceof ApiError && error.statusCode >= 500) {
toast.error("Something went wrong on our end.");
}
},
}),
});useEffect(() =>
api.onAuthStateChange((s) => {
if (!s.isAuthenticated) queryClient.clear();
}), [queryClient]);Essential with multi-tab sync: signing out in another tab must not leave user data in this tab's cache.
⚠️ Do not usethrowError: falsewith React Query. A 500 would resolve successfully and be cached as data — no error state, no retry. The repo's verification suite documents this exact hazard.
import useSWR from "swr";
import { ApiError } from "@mrzr/api-client";
import { api } from "@/lib/api";
const fetcher = <T,>(url: string) => api.get<T>(url).then((r) => r.data);
export function useUsers() {
const { data, error, isLoading } = useSWR<User[], ApiError>("/users", fetcher);
return { users: data ?? [], error, isLoading };
}Because the client rejects on failure, error is a real ApiError and SWR's retry logic works as designed.
<SWRConfig
value={{
fetcher,
onErrorRetry: (error, _key, _cfg, revalidate, { retryCount }) => {
if (error instanceof ApiError && error.statusCode < 500) return;
if (retryCount >= 3) return;
setTimeout(() => revalidate({ retryCount }), 1000 * 2 ** retryCount);
},
}}
>
<App />
</SWRConfig>import { useEffect, useState } from "react";
import { ApiError } from "@mrzr/api-client";
export function useApi<R>(fn: (signal: AbortSignal) => Promise<IRes<R>>, deps: unknown[] = []) {
const [data, setData] = useState<R>();
const [error, setError] = useState<ApiError>();
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(undefined);
fn(controller.signal)
.then((res) => setData(res.data))
.catch((e) => {
if (e instanceof ApiError && e.canceled) return; // unmounted
setError(e as ApiError);
})
.finally(() => setLoading(false));
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { data, error, loading };
}
// usage
const { data, error, loading } = useApi((signal) => api.get<User[]>("/users", { signal }));Enable cancellation once on the client:
export const api = createClient({ baseUrl, cancel: true });React — a scope per component. Everything it started dies with it, no controller to thread through:
function ProductPanel({ id }: { id: string }) {
const scope = useMemo(() => api.cancelScope(`product-${id}`), [id]);
useEffect(() => () => scope.cancel(), [scope]);
useEffect(() => {
scope.get<Product>(`/api/v1/products/${id}`).then(setProduct).catch(skipCanceled);
}, [scope, id]);
}
const skipCanceled = (e: unknown) => {
if (e instanceof ApiError && e.canceled) return;
throw e;
};Next.js App Router:
"use client";
const pathname = usePathname();
useEffect(() => () => api.cancel(), [pathname]);Next.js Pages Router:
router.events.on("routeChangeStart", () => api.cancel());Vue:
const scope = api.cancelScope("product-detail");
onUnmounted(() => scope.cancel());Vue Router:
router.beforeEach((to, from, next) => {
if (to.path !== from.path) api.cancel();
next();
});Svelte:
import { onDestroy } from "svelte";
const scope = api.cancelScope("dashboard");
onDestroy(() => scope.cancel());Angular:
@Component({ /* … */ })
export class ProductComponent implements OnDestroy {
private scope = api.cancelScope("product");
ngOnDestroy() { this.scope.cancel(); }
}TanStack Query / SWR: react-query hands your queryFn a signal — wire it through and it cancels for you. Mutations get no signal and aren't canceled on unmount, so use a scope there.
useQuery({ queryKey: ["users"], queryFn: ({ signal }) => api.get<User[]>("/users", { signal }).then(r => r.data) });Narrow any of these to a single screen's endpoints with a URL pattern — api.cancel("/api/v1/products"). Full recipes for every framework, plus the react-query and SWR specifics: Cancellation.
// lib/server-api.ts
import { createClient } from "@mrzr/api-client";
import { cookies } from "next/headers";
export async function withApi<T>(fn: (api: ApiClient) => Promise<T>): Promise<T> {
const api = createClient({
baseUrl: process.env.API_URL,
worker: false, // no Worker on the server
multiTab: false, // keeps the event loop clean
});
const token = (await cookies()).get("access_token")?.value;
if (token) await api.setTokens({ accessToken: token });
try {
return await fn(api);
} finally {
api.destroy();
}
}// app/users/page.tsx
export default async function UsersPage() {
const users = await withApi((api) =>
api.get<User[]>("/users", { cache: "no-store" }).then((r) => r.data ?? []),
);
return <UserList users={users} />;
}Next's fetch cache options pass straight through:
await api.get("/config", { cache: "force-cache" });
await api.get("/feed", { next: { revalidate: 60 } } as never);// lib/api.ts
"use client";
import { createClient } from "@mrzr/api-client";
export const api = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
storage: "memory",
});Import this only from client components. Server components should use the per-request factory above so tokens are never shared between users.
Never create a module-level client with tokens on the server. Modules are shared across requests, so one user's token could leak into another user's response.
Keep tokens entirely server-side by proxying:
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { withApi } from "@/lib/server-api";
export async function GET() {
const users = await withApi((api) => api.get<User[]>("/users").then((r) => r.data));
return NextResponse.json(users);
}The browser then calls /api/users with a same-site httpOnly cookie and never sees the upstream token.
// plugins/api.ts
import { createClient } from "@mrzr/api-client";
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const api = createClient({
baseUrl: config.public.apiUrl,
worker: import.meta.client,
multiTab: import.meta.client,
});
return { provide: { api } };
});<script setup lang="ts">
const { $api } = useNuxtApp();
const { data: users, error } = await useAsyncData("users", () =>
$api.get<User[]>("/users").then((r) => r.data ?? []),
);
</script>
<template>
<div v-if="error">{{ error.message }}</div>
<ul v-else><li v-for="u in users" :key="u.id">{{ u.name }}</li></ul>
</template>When your Nuxt server routes set httpOnly cookies (/api/auth/*), the API lives
on the same origin as the app, and the tokens are unreadable from JS.
// plugins/api.client.ts ← .client so it never runs during SSR
import { createClient } from "@mrzr/api-client";
export default defineNuxtPlugin(() => {
const api = createClient({
// Same-origin routes: leave baseUrl empty and let them resolve
// against the current origin. Only set it for a separate API host.
authMode: "cookie",
credentials: "include",
loginUrl: "/api/auth/login",
refreshUrl: "/api/auth/refresh",
logoutUrl: "/api/auth/logout",
});
return { provide: { api } };
});Then detect an existing session once, on startup — the cookie survives a reload but is invisible to JS, so the client has to ask:
<script setup lang="ts">
const { $api } = useNuxtApp();
const state = ref(await $api.restoreSession("/api/auth/me"));
async function login() {
await $api.login({ email: email.value, password: password.value });
state.value = await $api.getAuthState(); // now { isAuthenticated: true, user }
}
async function logout() {
await $api.logout();
state.value = await $api.getAuthState();
}
</script>A few things worth getting right here:
-
Use
plugins/api.client.ts, notplugins/api.ts. A universal plugin also runs on the server, where each request would build its own client. -
You no longer need
baseUrl: window.location.origin. That workaround was necessary before v1.0.2: a Blob worker's base is ablob:URL, which relative paths cannot resolve against, so requests failed in worker mode. The host now falls back to the page origin automatically. Setting it manually still works, but guard it for SSR —windowdoesn't exist there. -
restoreSession()is what makes reloads work. Without itgetAuthState()reportsfalseon a fresh load even though the cookie is present and requests succeed. - Cookies must reach the server: keep
credentials: "include", and for a separate API origin the server needsAccess-Control-Allow-Credentials: trueplus an explicit (non-*) allowed origin.
// lib/api.ts
export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL });<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ApiError } from "@mrzr/api-client";
import { api } from "@/lib/api";
const users = ref<User[]>([]);
const error = ref<string>();
const loading = ref(true);
onMounted(async () => {
try {
const { data } = await api.get<User[]>("/users");
users.value = data ?? [];
} catch (e) {
error.value = (e as ApiError).message;
} finally {
loading.value = false;
}
});
</script>Prefer no try/catch in components? Create the client with throwError: false and branch on res.status.
// src/lib/api.ts
import { createClient } from "@mrzr/api-client";
import { browser } from "$app/environment";
import { PUBLIC_API_URL } from "$env/static/public";
export const api = createClient({
baseUrl: PUBLIC_API_URL,
worker: browser,
multiTab: browser,
});// +page.ts
export const load = async ({ fetch }) => ({
users: (await api.get<User[]>("/users")).data ?? [],
});<script lang="ts">
import { api } from "$lib/api";
let promise = api.get<User[]>("/users");
</script>
{#await promise}
<Spinner />
{:then res}
<UserList users={res.data ?? []} />
{:catch error}
<ErrorBox message={error.message} />
{/await}// api.service.ts
import { Injectable } from "@angular/core";
import { createClient, type ApiClient } from "@mrzr/api-client";
import { environment } from "../environments/environment";
@Injectable({ providedIn: "root" })
export class ApiService {
readonly client: ApiClient = createClient({ baseUrl: environment.apiUrl });
users() {
return this.client.get<User[]>("/users").then((r) => r.data ?? []);
}
ngOnDestroy() { this.client.destroy(); }
}Bridging to RxJS:
import { from, defer } from "rxjs";
users$ = defer(() => from(this.client.get<User[]>("/users"))).pipe(
map((res) => res.data ?? []),
);import { createClient } from "@mrzr/api-client";
const api = createClient({
baseUrl: process.env.API_URL,
worker: false,
multiTab: false, // important: keeps the process from hanging
timeout: 15_000,
});
try {
await api.login({ email: process.env.EMAIL, password: process.env.PASSWORD });
const { data } = await api.get<Report[]>("/reports");
console.log(data);
} finally {
api.destroy();
}multiTab is auto-disabled on the server anyway (a ref'd BroadcastChannel would keep the event loop alive), but being explicit documents the intent.
export default {
async fetch(request: Request, env: Env) {
const api = createClient({
baseUrl: env.API_URL,
worker: false,
multiTab: false,
});
try {
const { data } = await api.get("/health");
return Response.json(data);
} finally {
api.destroy();
}
},
};Create the client inside the handler, not at module scope — module state is shared across requests and isolates.
<script type="module">
import { createClient } from "https://esm.sh/@mrzr/api-client";
const api = createClient({ baseUrl: "https://api.example.com" });
const { data } = await api.get("/health");
document.body.textContent = JSON.stringify(data);
</script>Next: Cookbook
Getting started
Requests
Authentication
Advanced
- Web Worker Isolation
- Multi-Tab Sync
- WebSockets and Socket.io
- Plugins
- Logging and Observability
- Security Model
Reference
Guides