structyl

@structyl/api-client

beta

Lightweight 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-clientTanStack QuerySWR
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

bash
pnpm add @structyl/api-client axios

2. Create a client

Call createApiClient once — typically in lib/api.ts. The client handles Axios instance creation, auth headers, and token refresh automatically.

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

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

tsx
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

useApiQuery › idle

Queries

useApiQuery fetches data, caches it with a configurable staleTime, and subscribes your component to cache updates via useSyncExternalStore.

Overloads

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

PropTypeDefaultDescription
enabledbooleantrueSet to false to disable automatic fetching. Useful for dependent queries.
staleTimenumber60_000Milliseconds before cached data is considered stale and eligible for a background refetch.
gcTimenumber300_000Milliseconds of inactivity before the cache entry is garbage collected.
retrynumber | false1Number of times to retry a failed request. Set to false to disable retries.
refetchOnWindowFocusbooleantrueRefetch when the browser window regains focus, if the data is stale.
pollIntervalnumberIf set, refetches on this interval (in ms). Useful for live dashboards.
select(data: TData) => TSelectedTransform or filter the response before it is returned to the component.
initialDataTDataPre-populate the cache synchronously before the first network request.
placeholderDataTSelectedShow this data while loading. isPlaceholderData is true when active.
keepPreviousDatabooleanfalseKeep previous data visible while a new key is loading (e.g. pagination).
debouncenumberDebounce the fetch by this many ms. Ideal for search-as-you-type.

Return values

FieldTypeDescription
dataTSelected | undefinedThe fetched (and optionally transformed) data. undefined while loading or on error.
isLoadingbooleanTrue when loading and there is no cached data yet (initial load skeleton state).
isFetchingbooleanTrue during any in-flight request, including background refetches.
isRefetchingbooleanTrue when re-fetching while stale cached data is still visible.
isPlaceholderDatabooleanTrue when placeholderData is being shown instead of real data.
isSuccessbooleanTrue once data has been fetched at least once successfully.
isErrorbooleanTrue when the last fetch attempt failed.
errorApiError | nullThe last error object, or null if no error.
status'idle' | 'loading' | 'success' | 'error'The raw cache entry status string.
refetch() => voidForce a fresh request, bypassing staleTime.

Common patterns

Conditional query (dependent on another)

tsx
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

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

tsx
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

tsx
const { data: jobStatus } = useApiQuery('/jobs/123/status', {
  pollInterval: 3_000, // poll every 3 seconds
  enabled: jobStatus?.state !== 'done', // stop when complete
});

Pagination with keepPreviousData

tsx
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

tsx
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

useApiMutation — POST /users

A

Alice Chen

Engineer

B

Bob Smith

Designer

C

Carol Wu

Product

Options

PropTypeDefaultDescription
method'POST' | 'PUT' | 'PATCH' | 'DELETE''POST'HTTP method for the request.
invalidatesunknown[][]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) => voidCalled when the request fails. Optimistic updates are rolled back before this fires.
optimisticOptimisticConfigApply an optimistic update before the request, with automatic rollback on error.
onUploadProgress(percentage: number) => voidUpload progress callback (0–100). Useful for file uploads.

Return values

FieldTypeDescription
mutate(variables: TVariables) => voidFire-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.
dataTData | undefinedThe last successful response data.
isPendingbooleanTrue while the request is in flight.
isSuccessbooleanTrue after the last request succeeded.
isErrorbooleanTrue after the last request failed.
errorApiError | nullThe last error.
reset() => voidReset 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.

tsx
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

tsx
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

Key
Status
Age
/users
success
12s
/products
stale
65s
/orders
⟳ loading
0s

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.

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

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

tsx
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>
  );
}
TIP:Each entry has its own 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.

tsx
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=…

Apples
Bananas

2 / 8 items · page 1

Custom page fetcher (offset pagination)

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

NOTE:Unlike useApiQuery, the returned data is non-nullable — it is guaranteed to be defined once the component renders. No undefined check needed.
tsx
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

FieldTypeDescription
dataTDataNon-nullable. Guaranteed to be defined when the component is mounted.
isFetchingbooleanTrue during a background refetch (does not cause suspension).
isRefetchingbooleanTrue during a background refetch while stale data is shown.
isSuccesstrueAlways true — Suspense ensures this hook only renders on success.
refetch() => voidTrigger 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

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

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

tsx
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.
WARNING:Serialization uses 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.

PropTypeDefaultDescription
baseURLstringBase URL prepended to every request.
headersRecord<string, string>Static headers sent on every request.
timeoutnumber10_000Request 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) => voidCalled when the token refresh itself fails (e.g. to redirect to login).

new QueryClient(config)

PropTypeDefaultDescription
gcTimenumber300_000Garbage collect unused cache entries after this many ms of inactivity.
onError(error: ApiError, key: string) => voidGlobal error listener — called after every failed fetch.
onSuccess(data: unknown, key: string) => voidGlobal success listener — called after every successful fetch.

ApiProvider

PropTypeDefaultDescription
clientApiClientThe ApiClient instance from createApiClient().
queryClientQueryClientThe QueryClient instance. Manages the cache and GC.
childrenReact.ReactNodeYour app.

useApiClient()

Returns the raw Axios instance for one-off requests or non-hook usage.

tsx
const { instance } = useApiClient();
const res = await instance.get('/download', { responseType: 'blob' });

QueryClient methods

FieldTypeDescription
invalidateQueries({ queryKey })Promise<void>Mark a cached entry stale and trigger a refetch in all active subscribers.
setQueryData(key, updater)voidWrite data directly to the cache, bypassing the network.
getQueryData<T>(key)T | undefinedRead current data for a key without subscribing.
cancelQueries({ queryKey })Promise<void>Abort the in-flight request for a key (used before optimistic updates).
clear()voidWipe the entire cache (e.g. on logout).

ApiError shape

ts
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
}