Tiny React 19 hooks for axios — built on use() + Suspense, with no long-lived cache.
import { useGET } from '@laststance/use-get'
function UserCard({ id }: { id: number }) {
const user = useGET<User>(`/api/users/${id}`)
return <div>{user.name}</div>
}No queryKey. No invalidate. No staleTime. No provider. ~2KB gzipped (excluding peer deps).
| You want… | Use this lib? |
|---|---|
| Single GET in a component, Suspense-integrated | ✅ |
| A mutation (POST/PUT/PATCH/DELETE) | ✅ |
Optimistic UI with React 19's useOptimistic |
✅ |
| Pagination, infinite scroll | ❌ |
| Cache invalidation across a dependency graph | ❌ |
| Background refetch on window focus | ❌ |
| DevTools with request timeline | ❌ |
For anything in the ❌ column, reach for TanStack Query or SWR. This lib is intentionally the smaller of the two — keep both in the same app if you need.
TanStack Query and SWR are caches. This is not a cache. The mental model is closer to fetch() wrapped in Suspense.
- A
useGETcall fires a request when it mounts. - Concurrent siblings on the same URL share one in-flight request (request coalescing — browsers don't dedupe concurrent requests, so forcing duplicates would just waste bandwidth).
- Once the last consumer unmounts, the entry is evicted. A remount fires a fresh request.
- Freshness (Cache-Control, ETag, 304) is the browser's HTTP cache's job. Service Workers can extend this further.
What you don't get:
- No
staleTimeorgcTime— nothing to tune. - No
queryKey— the URL + params IS the key. - No
invalidate('users')— there's no cache to invalidate. Unmount + remount gets you fresh data. - No background refetch — that's a caching feature.
What you do get:
- Predictability. If you see a
useGETrender and don't see a request in the network tab, something is broken. - No surprise stale renders after navigation.
- Smaller mental model: Suspense fallback → data appears → unmount → gone.
pnpm add @laststance/use-get axiosPeers: react@^19, axios@^1.7.
One-time setup. Call once at app startup. No provider needed.
import { configureClient } from '@laststance/use-get'
configureClient({
baseURL: 'https://api.example.com',
timeout: 10_000,
headers: {
Authorization: () => `Bearer ${getToken()}`, // functions re-evaluate per request
},
})Suspends while the request is pending. Throws to the nearest Error Boundary on failure.
const user = useGET<User>('/api/users/1')
const list = useGET<Post[]>('/api/posts', {
params: { page: 1, _limit: 20 },
retries: 3, // default 3 — GET is idempotent, safe to retry
timeout: 5_000, // default 10_000
})Always wrap in <Suspense> + <ErrorBoundary> upstream.
One hook per HTTP verb — method is visible at the call site instead of buried in config.
const { mutate, isPending, data, error, reset } = usePOST<CreateUser, User>({
url: '/api/users',
onSuccess: (user, req) => console.log('created', user),
onError: (err, req) => console.error(err),
retries: 0, // default 0 — non-idempotent, don't retry by default
})
await mutate({ name: 'Ada' })Dynamic URL:
const { mutate } = useDELETE<{ id: number }, void>({
url: (req) => `/api/users/${req.id}`,
})import { getStatus, isClientError, isServerError, isRetryable } from '@laststance/use-get'
if (isClientError(err)) { /* 400–499 */ }
if (isServerError(err)) { /* 500–599 */ }
if (isRetryable(err)) { /* 408, 429, 500, 502, 503, 504, ECONNABORTED */ }Optimistic UI is not in this library. It's a React 19 primitive (useOptimistic) that you compose with the mutation hook:
import { useGET, usePOST } from '@laststance/use-get'
import { useOptimistic, useState, useTransition } from 'react'
function Todos() {
const todos = useGET<Todo[]>('/api/todos')
const [optimistic, addOptimistic] = useOptimistic(
todos,
(state, next: Todo) => [...state, next],
)
const { mutate } = usePOST<Partial<Todo>, Todo>({ url: '/api/todos' })
const [, startTransition] = useTransition()
function add(title: string) {
startTransition(async () => {
addOptimistic({ id: -Date.now(), title, completed: false })
await mutate({ title, completed: false })
// No cache to invalidate. Next unmount/remount fetches fresh.
})
}
return <ul>{optimistic.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}They coexist cleanly — they share axios (via configureClient) but have no other runtime overlap.
- Use TanStack Query for: paginated lists, infinite scroll, cross-component cache coherence, anything needing
invalidate. - Use this lib for: the one-off GET in a settings page or a dashboard tile where a
useQuery({ queryKey: ['settings'], queryFn: ... })feels like overkill.
- Wrap every
useGETtree in both<Suspense>and<ErrorBoundary>(React 19 still requires a class component for Error Boundaries). - Call
configureClientonce, at module load — not inside a component. - Let concurrent siblings on the same URL share one request (that's coalescing working as intended).
- Keep
usePOSTretries: 0unless you're sure the endpoint is idempotent.
- Don't call
useGETconditionally, inside loops, or after early returns. React'suse()has the same rules as other hooks, plus more (see React docs onuse). - Don't expect the library to dedupe requests after unmount. A remount of the same URL = a new request, by design.
- Don't try to mutate the returned data. The Promise result is shared across concurrent consumers — treat it as immutable.
- Don't use this in RSC (Server Components). For server-rendered data,
await axios.get(url)directly — you don't need Suspense integration there.
pnpm install
pnpm build
pnpm --filter vite-spa-example devOpen http://localhost:5173. The example proxies /api/* to JSONPlaceholder so you can play with three demos (basic GET, URL-change, optimistic POST) without standing up a backend.
pnpm test # Vitest Browser Mode (Playwright chromium)
pnpm typecheckReal-browser tests — use() and Suspense behave differently in JSDOM, and the stakes of getting this wrong are too high to mock.
MIT © Laststance.io