@structyl/api-client
betaLightweight data-fetching for React 18. Axios-powered with a built-in cache, automatic deduplication, retries, polling, optimistic mutations, infinite scroll, and SSR support — all without TanStack Query.
useSyncExternalStore cache
React 18 concurrent-safe, no context thrash
Request deduplication
One in-flight request per cache key
Smart invalidation
Generation counter prevents stale writes
Optimistic mutations
Rollback on error, stable with Suspense
SSR dehydrate/hydrate
Prefetch on server, reuse on client
Zero extra deps
Only axios + react as peer dependencies
vs other libraries
| Feature | @structyl/api-client | TanStack Query | SWR |
|---|---|---|---|
| Bundle size | ~6 kB | ~35 kB | ~11 kB |
| Axios built-in | ✓ | ✗ (bring your own) | ✗ |
| Auth token injection | ✓ built-in | ✗ manual | ✗ manual |
| Token refresh | ✓ built-in | ✗ manual | ✗ manual |
| Optimistic updates | ✓ | ✓ | ✓ |
| Infinite scroll | ✓ | ✓ | ✓ |
| Suspense | ✓ | ✓ | ✓ |
| SSR / dehydrate | ✓ | ✓ | ✓ |
| DevTools | ✓ (subpath) | ✓ | ✗ |
Quick Start
1. Install
pnpm add @structyl/api-client axios2. Create a client
Call createApiClient once — typically in lib/api.ts. The client handles Axios instance creation, auth headers, and token refresh automatically.
// lib/api.ts
import { createApiClient, QueryClient } from '@structyl/api-client';
export const apiClient = createApiClient({
baseURL: 'https://api.example.com',
// Inject the Bearer token on every request
getAuthToken: () => localStorage.getItem('token'),
// Called automatically when a 401 is received
refreshToken: async () => {
const res = await fetch('/api/auth/refresh', { method: 'POST' });
const { token } = await res.json();
localStorage.setItem('token', token);
return token;
},
// Called if refresh itself fails (e.g. redirect to login)
onRefreshError: () => { window.location.href = '/login'; },
timeout: 10_000,
headers: { 'X-App-Version': '1.0.0' },
});
export const queryClient = new QueryClient({
gcTime: 5 * 60_000, // garbage-collect unused entries after 5 min
onError: (err) => console.error(err),
});3. Wrap your app
// app/layout.tsx (or _app.tsx in pages router)
import { ApiProvider } from '@structyl/api-client';
import { apiClient, queryClient } from '@/lib/api';
export default function RootLayout({ children }) {
return (
<ApiProvider client={apiClient} queryClient={queryClient}>
{children}
</ApiProvider>
);
}4. Fetch data
import { useApiQuery } from '@structyl/api-client';
interface User { id: number; name: string; email: string }
export function UserList() {
const { data, isLoading, error, refetch } = useApiQuery<User[]>('/users');
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<>
{data?.map(user => <UserCard key={user.id} user={user} />)}
<button onClick={refetch}>Refresh</button>
</>
);
}Live demo
Queries
useApiQuery fetches data, caches it with a configurable staleTime, and subscribes your component to cache updates via useSyncExternalStore.
Overloads
// Overload 1 — URL is both key and fetcher (most common)
const { data } = useApiQuery<User[]>('/users');
// Overload 2 — Separate key + URL (e.g. key includes variables)
const { data } = useApiQuery<User>(['/users', userId], `/users/${userId}`);
// Overload 3 — Separate key + custom fetcher
const { data } = useApiQuery<User>(
['/users', userId],
(axios) => axios.get(`/users/${userId}`).then(r => r.data),
);Options
| Prop | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | true | Set to false to disable automatic fetching. Useful for dependent queries. |
| staleTime | number | 60_000 | Milliseconds before cached data is considered stale and eligible for a background refetch. |
| gcTime | number | 300_000 | Milliseconds of inactivity before the cache entry is garbage collected. |
| retry | number | false | 1 | Number of times to retry a failed request. Set to false to disable retries. |
| refetchOnWindowFocus | boolean | true | Refetch when the browser window regains focus, if the data is stale. |
| pollInterval | number | — | If set, refetches on this interval (in ms). Useful for live dashboards. |
| select | (data: TData) => TSelected | — | Transform or filter the response before it is returned to the component. |
| initialData | TData | — | Pre-populate the cache synchronously before the first network request. |
| placeholderData | TSelected | — | Show this data while loading. isPlaceholderData is true when active. |
| keepPreviousData | boolean | false | Keep previous data visible while a new key is loading (e.g. pagination). |
| debounce | number | — | Debounce the fetch by this many ms. Ideal for search-as-you-type. |
Return values
| Field | Type | Description |
|---|---|---|
| data | TSelected | undefined | The fetched (and optionally transformed) data. undefined while loading or on error. |
| isLoading | boolean | True when loading and there is no cached data yet (initial load skeleton state). |
| isFetching | boolean | True during any in-flight request, including background refetches. |
| isRefetching | boolean | True when re-fetching while stale cached data is still visible. |
| isPlaceholderData | boolean | True when placeholderData is being shown instead of real data. |
| isSuccess | boolean | True once data has been fetched at least once successfully. |
| isError | boolean | True when the last fetch attempt failed. |
| error | ApiError | null | The last error object, or null if no error. |
| status | 'idle' | 'loading' | 'success' | 'error' | The raw cache entry status string. |
| refetch | () => void | Force a fresh request, bypassing staleTime. |
Common patterns
Conditional query (dependent on another)
const { data: user } = useApiQuery('/me');
// Only runs when user is loaded
const { data: posts } = useApiQuery(
['/posts', user?.id],
`/users/${user?.id}/posts`,
{ enabled: !!user?.id },
);Select transform — shape data per component
// Raw type: { users: User[]; total: number }
// Transformed: User[]
const { data: activeUsers } = useApiQuery<ApiResponse, User[]>(
'/users',
{
select: (res) => res.users.filter(u => u.active),
}
);Search-as-you-type with debounce
function Search({ query }: { query: string }) {
const { data } = useApiQuery<Result[]>(
['/search', query],
`/search?q=${query}`,
{
debounce: 300, // wait 300ms after last keystroke
keepPreviousData: true, // keep old results visible while loading
enabled: query.length > 1,
}
);
// ...
}Polling — live data without WebSockets
const { data: jobStatus } = useApiQuery('/jobs/123/status', {
pollInterval: 3_000, // poll every 3 seconds
enabled: jobStatus?.state !== 'done', // stop when complete
});Pagination with keepPreviousData
function UserTable({ page }: { page: number }) {
const { data, isPlaceholderData } = useApiQuery<User[]>(
['/users', page],
`/users?page=${page}&limit=20`,
{ keepPreviousData: true },
);
return (
<div style={{ opacity: isPlaceholderData ? 0.7 : 1 }}>
{data?.map(u => <Row key={u.id} user={u} />)}
</div>
);
}Mutations
useApiMutation wraps POST/PUT/PATCH/DELETE requests with status tracking, cache invalidation, and optimistic updates. It does not touch the cache until mutate() is called.
Basic usage
import { useApiMutation } from '@structyl/api-client';
interface CreateUser { name: string; email: string }
function CreateUserForm() {
const { mutate, mutateAsync, isPending, isError, error, reset } =
useApiMutation<User, CreateUser>('/users', {
method: 'POST',
// Invalidate the /users list cache after success → triggers refetch
invalidates: [['/users']],
onSuccess: (user, variables) => {
toast.success(`Created ${user.name}`);
},
onError: (err) => {
toast.error(err.message);
},
});
return (
<form onSubmit={e => {
e.preventDefault();
const data = new FormData(e.currentTarget);
mutate({ name: data.get('name'), email: data.get('email') });
}}>
<input name="name" />
<input name="email" />
<button disabled={isPending}>{isPending ? 'Saving…' : 'Create'}</button>
{isError && <p>{error?.message} <button onClick={reset}>Dismiss</button></p>}
</form>
);
}Live demo — mutation + invalidation
Alice Chen
Engineer
Bob Smith
Designer
Carol Wu
Product
Options
| Prop | Type | Default | Description |
|---|---|---|---|
| method | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'POST' | HTTP method for the request. |
| invalidates | unknown[][] | — | Array of query keys to invalidate on success. Each element is a key array matching useApiQuery. |
| onSuccess | (data, variables) => void | Promise<void> | — | Called after the request succeeds and cache invalidation is complete. |
| onError | (error: ApiError) => void | — | Called when the request fails. Optimistic updates are rolled back before this fires. |
| optimistic | OptimisticConfig | — | Apply an optimistic update before the request, with automatic rollback on error. |
| onUploadProgress | (percentage: number) => void | — | Upload progress callback (0–100). Useful for file uploads. |
Return values
| Field | Type | Description |
|---|---|---|
| mutate | (variables: TVariables) => void | Fire-and-forget mutation. Errors are swallowed; observe isError instead. |
| mutateAsync | (variables: TVariables) => Promise<TData> | Returns a promise. Throws ApiError on failure. Use inside async event handlers. |
| data | TData | undefined | The last successful response data. |
| isPending | boolean | True while the request is in flight. |
| isSuccess | boolean | True after the last request succeeded. |
| isError | boolean | True after the last request failed. |
| error | ApiError | null | The last error. |
| reset | () => void | Reset state back to idle. |
Optimistic updates
Pass an optimistic config to apply a UI update instantly while the request is in flight. If the request fails, the original data is restored automatically.
const { mutate } = useApiMutation<Post, { id: number; liked: boolean }>(
'/posts/like',
{
method: 'PATCH',
optimistic: {
queryKey: ['/posts'],
updater: (oldPosts, { id, liked }) =>
oldPosts?.map(p => p.id === id ? { ...p, liked, likeCount: p.likeCount + (liked ? 1 : -1) } : p) ?? [],
},
// On error: old posts are automatically restored
onError: (err) => toast.error('Failed to like — reverted'),
}
);
// UI responds immediately
mutate({ id: post.id, liked: !post.liked });Live demo — optimistic like
optimistic — PATCH /posts/:id/like
Shipped the new API client 🎉
Zero deps + useSyncExternalStore cache
RTK Query-style docs are live
UI updates instantly; server syncs in background
File uploads with progress
function AvatarUpload() {
const [progress, setProgress] = React.useState(0);
const { mutate, isPending } = useApiMutation<{ url: string }, FormData>(
'/upload/avatar',
{
method: 'POST',
onUploadProgress: (pct) => setProgress(pct),
onSuccess: ({ url }) => updateAvatar(url),
}
);
const onFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
mutate(fd);
};
return (
<>
<input type="file" onChange={onFile} accept="image/*" />
{isPending && <progress value={progress} max={100} />}
</>
);
}Cache behavior
The cache is a simple in-memory key→value store. Each entry has a status (idle | loading | success | error), a updatedAt timestamp, and a generation counter that prevents stale in-flight writes from landing.
QueryCache visualization
staleTime: 60 000ms — entries older than 60s are stale
Staleness
Data is fresh for staleTime ms after it was last fetched. A stale entry is served immediately and refetched in the background on the next component mount or window focus. Setting staleTime: Infinity effectively disables background refetching.
// Never stale — fetch once and cache forever (per session)
const { data } = useApiQuery('/config', { staleTime: Infinity });
// Always stale — always refetch on mount
const { data } = useApiQuery('/live-prices', { staleTime: 0 });External invalidation
Mutations call queryClient.invalidateQueries which sets a sentinel (updatedAt = 0) on the entry. Active query hooks watching that key detect the sentinel and trigger a fresh fetch,without triggering an infinite loop when staleTime: 0.
// Manually invalidate any key from anywhere
import { queryClient } from '@/lib/api';
queryClient.invalidateQueries({ queryKey: ['/users'] });
// Set data directly (skip network, e.g. after a mutation response)
queryClient.setQueryData(['/users', 1], updatedUser);
// Read current data without subscribing
const user = queryClient.getQueryData<User>(['/users', 1]);
// Cancel in-flight request for a key (e.g. before optimistic update)
await queryClient.cancelQueries({ queryKey: ['/users', 1] });Garbage collection
Unused cache entries (no active subscribers) are removed after gcTime ms (default 5 min). Configure it in new QueryClient({ gcTime: ... }).
Parallel Queries
useApiQueries runs multiple queries in parallel and returns a stable-snapshot array — updating only when at least one entry changes.
import { useApiQueries } from '@structyl/api-client';
function Dashboard({ userId }: { userId: string }) {
const results = useApiQueries([
{ url: '/stats/revenue' },
{ url: '/stats/users' },
{ url: `/users/${userId}/activity`, key: ['activity', userId] },
{
url: '/products',
options: {
select: (products) => products.filter(p => p.featured),
staleTime: 5 * 60_000,
},
},
]);
const [revenue, users, activity, featured] = results;
if (results.some(r => r.isLoading)) return <Skeleton />;
return (
<Grid>
<StatCard value={revenue.data?.total} label="Revenue" />
<StatCard value={users.data?.count} label="Users" />
<ActivityFeed items={activity.data ?? []} />
<FeaturedProducts items={featured.data ?? []} />
</Grid>
);
}refetch() — callingresults[1].refetch() only re-fetches the users query, leaving the others untouched.Infinite Scroll
useInfiniteApiQuery manages paginated data as a list of pages. By default it appends ?cursor= to the URL for each page. Pass a custom fetchPage for offset/page-number pagination.
import { useInfiniteApiQuery } from '@structyl/api-client';
interface PostsPage { posts: Post[]; nextCursor: string | null }
function Feed() {
const {
data, // { pages: PostsPage[], pageParams: unknown[] }
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
refetch,
} = useInfiniteApiQuery<PostsPage>('/posts', {
// URL becomes /posts?cursor=<nextCursor> automatically
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
// Optional: support bidirectional scrolling
getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
staleTime: 2 * 60_000,
retry: 2,
});
const posts = data?.pages.flatMap(p => p.posts) ?? [];
return (
<>
{posts.map(post => <PostCard key={post.id} post={post} />)}
<button onClick={fetchNextPage} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading…' : hasNextPage ? 'Load more' : 'All caught up'}
</button>
</>
);
}Live demo
useInfiniteApiQuery — GET /items?cursor=…
2 / 8 items · page 1
Custom page fetcher (offset pagination)
const { data, fetchNextPage } = useInfiniteApiQuery<UserPage>('/users', {
getNextPageParam: (last, all) =>
last.hasMore ? all.length : undefined, // page index = array length
fetchPage: async (pageParam, axios) => {
const page = pageParam as number ?? 0;
const res = await axios.get('/users', { params: { offset: page * 20, limit: 20 } });
return res.data;
},
});Suspense
useSuspenseApiQuery integrates with React Suspense. It throws a Promise on the initial load (caught by the nearest <Suspense> boundary) and throws an ApiError on failure (caught by an ErrorBoundary). Background refetches never suspend — they run silently.
useApiQuery, the returned data is non-nullable — it is guaranteed to be defined once the component renders. No undefined check needed.import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useSuspenseApiQuery } from '@structyl/api-client';
// Child component — data is guaranteed non-null
function UserProfile({ id }: { id: string }) {
const { data: user, isFetching, refetch } = useSuspenseApiQuery<User>(
`/users/${id}`,
{ staleTime: 5 * 60_000 }
);
// data.name is safe — no optional chaining needed
return (
<div>
<h1>{user.name}</h1>
{isFetching && <RefreshIndicator />} {/* background refetch */}
<button onClick={refetch}>Refresh</button>
</div>
);
}
// Parent — provides fallback and error UI
function UserPage({ id }: { id: string }) {
return (
<ErrorBoundary fallback={<ErrorCard />}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile id={id} />
</Suspense>
</ErrorBoundary>
);
}Return values
| Field | Type | Description |
|---|---|---|
| data | TData | Non-nullable. Guaranteed to be defined when the component is mounted. |
| isFetching | boolean | True during a background refetch (does not cause suspension). |
| isRefetching | boolean | True during a background refetch while stale data is shown. |
| isSuccess | true | Always true — Suspense ensures this hook only renders on success. |
| refetch | () => void | Trigger a background refresh without suspending. |
SSR / Server Rendering
Prefetch queries on the server, dehydrate the cache to JSON, send it to the client, and rehydrate before React renders — eliminating the initial loading spinner for server-rendered pages.
Next.js App Router
// app/users/page.tsx (Server Component)
import { prefetchApiQuery, dehydrate } from '@structyl/api-client/server';
import { apiClient, queryClient } from '@/lib/api';
import { HydrationBoundary } from '@structyl/api-client';
import { UserList } from './UserList';
export default async function UsersPage() {
// Prefetch on server — populates queryClient cache
await prefetchApiQuery(queryClient, apiClient, '/users');
return (
// Serialize the cache and send to client
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList /> {/* renders without a loading state */}
</HydrationBoundary>
);
}
// app/users/UserList.tsx ('use client')
// useApiQuery finds the prefetched data in cache → no loading state
function UserList() {
const { data } = useApiQuery('/users'); // instant!
return <>{data?.map(u => <UserCard key={u.id} user={u} />)}</>;
}Pages Router (getServerSideProps)
// pages/users.tsx
import { prefetchApiQuery, dehydrate } from '@structyl/api-client/server';
import { apiClient, queryClient } from '@/lib/api';
export async function getServerSideProps() {
await prefetchApiQuery(queryClient, apiClient, '/users');
return { props: { dehydratedState: dehydrate(queryClient) } };
}
export default function UsersPage({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<UserList />
</HydrationBoundary>
);
}Cache Persistence
Persist the cache to localStorage (or any storage implementing getItem/setItem/removeItem) so data survives page refreshes.
import { persistCache } from '@structyl/api-client';
import { queryClient } from '@/lib/api';
// Call once before ApiProvider renders, e.g. in app.tsx
await persistCache(queryClient, {
storage: window.localStorage, // or AsyncStorage, IndexedDB wrapper, etc.
key: 'my-app-cache', // storage key (default: 'structyl-cache')
maxAge: 24 * 60 * 60_000, // discard entries older than 24 h
});
// That's it — the cache is now hydrated from storage on page load
// and written to storage after every successful fetch.JSON.stringify. Do not persist sensitive data (auth tokens, PII) — store those in secure HttpOnly cookies instead.API Reference
createApiClient(config)
Creates the Axios-based API client. Returns an ApiClient instance.
| Prop | Type | Default | Description |
|---|---|---|---|
| baseURL | string | — | Base URL prepended to every request. |
| headers | Record<string, string> | — | Static headers sent on every request. |
| timeout | number | 10_000 | Request timeout in ms. |
| getAuthToken | () => string | null | Promise<…> | — | Called before each request to inject a Bearer token. |
| refreshToken | () => Promise<string> | — | Called automatically when a 401 response is received. Should return the new token. |
| onRefreshError | (err: unknown) => void | — | Called when the token refresh itself fails (e.g. to redirect to login). |
new QueryClient(config)
| Prop | Type | Default | Description |
|---|---|---|---|
| gcTime | number | 300_000 | Garbage collect unused cache entries after this many ms of inactivity. |
| onError | (error: ApiError, key: string) => void | — | Global error listener — called after every failed fetch. |
| onSuccess | (data: unknown, key: string) => void | — | Global success listener — called after every successful fetch. |
ApiProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| client | ApiClient | — | The ApiClient instance from createApiClient(). |
| queryClient | QueryClient | — | The QueryClient instance. Manages the cache and GC. |
| children | React.ReactNode | — | Your app. |
useApiClient()
Returns the raw Axios instance for one-off requests or non-hook usage.
const { instance } = useApiClient();
const res = await instance.get('/download', { responseType: 'blob' });QueryClient methods
| Field | Type | Description |
|---|---|---|
| invalidateQueries({ queryKey }) | Promise<void> | Mark a cached entry stale and trigger a refetch in all active subscribers. |
| setQueryData(key, updater) | void | Write data directly to the cache, bypassing the network. |
| getQueryData<T>(key) | T | undefined | Read current data for a key without subscribing. |
| cancelQueries({ queryKey }) | Promise<void> | Abort the in-flight request for a key (used before optimistic updates). |
| clear() | void | Wipe the entire cache (e.g. on logout). |
ApiError shape
interface ApiError {
status: number; // HTTP status code (0 for network errors)
message: string; // Human-readable message
data?: unknown; // Raw response body from the server, if any
}