fetchpro-js
v1.0.1
Published
Framework-free query client with caching, useQuery, useMutation, invalidateQueries, and IndexedDB persistence
Maintainers
Readme
fetchpro-js
Framework-free TypeScript library for managing and caching async requests.
It follows the same mental model as TanStack Query (queryKey, stale data, invalidate), but runs in plain JavaScript — no React, Vue, or other UI framework. You subscribe to observers and update the DOM (or any UI) yourself.
Features
useQuery— fetch, cache, loading/error/success state, refetchuseMutation— write operations with optional cache invalidation- In-memory cache — shared by query key across observers
invalidateQueries— mark stale and refetch active observers- IndexedDB persistence — cache survives page refresh
Install
npm install fetchpro-jsWorks in modern browsers (ES modules). IndexedDB persistence requires a browser environment.
Core concepts
| Concept | Meaning |
|--------|---------|
| QueryClient | Owns the cache. Create one and share it across your app. |
| queryKey | Array that identifies a cached result, e.g. ['todos'] or ['todo', id]. |
| queryFn | Async function that loads the data. |
| Observer | What useQuery / useMutation return. Call subscribe to react to state changes. |
| staleTime | How long cached data is considered fresh (ms). Fresh data skips automatic refetch. |
Flow:
- Create a
QueryClient - (Optional) Restore from IndexedDB with
persistQueryClient - Create observers with
useQuery/useMutation subscribe→ readgetState()→ update UI- Call
destroy()when a view is torn down
Quick start
import { createQueryClient, useQuery } from 'fetchpro-js'
const client = createQueryClient()
const users = useQuery(
{
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((r) => r.json()),
},
client
)
users.subscribe(() => {
const { data, isLoading, isError, error } = users.getState()
if (isLoading) {
console.log('Loading…')
return
}
if (isError) {
console.error(error)
return
}
console.log(data)
})Pass the client as the second argument, or set a default once:
import { createQueryClient, setDefaultQueryClient, useQuery } from 'fetchpro-js'
const client = createQueryClient()
setDefaultQueryClient(client)
// No second argument needed
const users = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((r) => r.json()),
})useQuery — reading data
Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| queryKey | readonly unknown[] | required | Cache identity |
| queryFn | () => Promise<T> | required | Fetcher |
| staleTime | number | 0 | Ms before data is stale |
| enabled | boolean | true | If false, no auto-fetch (refetch() still works) |
| retry | number | 1 | Retries after failure (delay grows per attempt) |
Returned API
| Method | Description |
|--------|-------------|
| getState() | Current { data, error, status, isLoading, isFetching, isError, isSuccess, … } |
| subscribe(cb) | Run cb on every state change; returns unsubscribe |
| refetch() | Force a fetch; returns a Promise |
| destroy() | Detach the observer (stop counting as “active”) |
Example: render into the DOM
import { createQueryClient, useQuery } from 'fetchpro-js'
const client = createQueryClient()
const listEl = document.getElementById('list')!
const statusEl = document.getElementById('status')!
const todos = useQuery(
{
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error('Failed to load todos')
return res.json() as Promise<{ id: number; title: string }[]>
},
staleTime: 30_000, // reuse cache for 30s
retry: 1,
},
client
)
const unsubscribe = todos.subscribe(() => {
const { data, isLoading, isFetching, isError, error } = todos.getState()
statusEl.textContent = isLoading
? 'Loading…'
: isFetching
? 'Refreshing…'
: isError
? `Error: ${error?.message}`
: ''
if (data) {
listEl.innerHTML = data.map((t) => `<li>${t.title}</li>`).join('')
}
})
// Manual refresh button
document.getElementById('refresh')!.addEventListener('click', () => {
void todos.refetch()
})
// When leaving the page/view:
// unsubscribe()
// todos.destroy()Example: dependent / disabled query
const userId = new URLSearchParams(location.search).get('id')
const user = useQuery(
{
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
enabled: Boolean(userId), // skip fetch until id exists
},
client
)Example: shared cache (two observers, one request)
// Both use the same key → one in-flight request, shared cache entry
const a = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }, client)
const b = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }, client)
// a.getState().data === b.getState().data after successuseMutation — writing data
Options
| Option | Description |
|--------|-------------|
| mutationFn(variables) | Required. Performs the write. |
| onSuccess(data, variables) | After success |
| onError(error, variables) | After failure |
| invalidateKeys | Query keys to invalidate after success (requires a QueryClient) |
Returned API
| Method | Description |
|--------|-------------|
| mutate(vars) | Fire-and-forget |
| mutateAsync(vars) | Returns a Promise |
| getState() / subscribe(cb) | Track isPending, isError, data, etc. |
| reset() | Back to idle |
| destroy() | Tear down |
Example: create + invalidate list
import { createQueryClient, useQuery, useMutation } from 'fetchpro-js'
const client = createQueryClient()
const todos = useQuery(
{
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
},
client
)
const createTodo = useMutation(
{
mutationFn: async (title: string) => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error('Create failed')
return res.json()
},
// After success, mark ['todos'] stale and refetch active observers
invalidateKeys: [['todos']],
onSuccess: () => {
console.log('Created')
},
},
client
)
document.getElementById('form')!.addEventListener('submit', (e) => {
e.preventDefault()
const title = new FormData(e.target as HTMLFormElement).get('title') as string
createTodo.mutate(title)
})
createTodo.subscribe(() => {
const { isPending, isError, error } = createTodo.getState()
const btn = document.querySelector('button[type=submit]') as HTMLButtonElement
btn.disabled = isPending
if (isError) alert(error?.message)
})Example: mutateAsync with per-call callbacks
try {
const created = await createTodo.mutateAsync('Buy milk', {
onSuccess: (data) => console.log('ok', data),
})
} catch {
// error already in createTodo.getState()
}Invalidation & cache helpers
// Prefix match: invalidates ['todos'], ['todos', 1], ['todos', 'active'], …
await client.invalidateQueries(['todos'])
// Exact match only
await client.invalidateQueries(['todos', 1], { exact: true })
// Read / write cache without fetching
const cached = client.getQueryData<{ id: number }[]>(['todos'])
client.setQueryData(['todos'], [{ id: 1, title: 'Cached item' }])
// Wipe in-memory cache
client.clear()When you invalidate, matching entries are marked stale. Observers that are still active (useQuery not destroyed) refetch automatically.
Multiple JS files (same page)
Share one QueryClient so all modules use the same cache.
// client.js
import { createQueryClient, setDefaultQueryClient } from 'fetchpro-js'
export const client = createQueryClient()
setDefaultQueryClient(client)// todos-page.js
import { useQuery } from 'fetchpro-js'
import { client } from './client.js'
export function mountTodos(root: HTMLElement) {
const query = useQuery(
{
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
},
client
)
const unsub = query.subscribe(() => {
const { data, isLoading } = query.getState()
root.textContent = isLoading ? 'Loading…' : JSON.stringify(data)
})
return () => {
unsub()
query.destroy()
}
}// admin-page.js — same client → sees the same ['todos'] cache
import { useMutation } from 'fetchpro-js'
import { client } from './client.js'Note: A full HTML navigation or refresh starts a new JS runtime. The in-memory cache is empty unless you enable IndexedDB persistence (below).
Persist across refresh (IndexedDB)
In-memory cache is lost on refresh. persistQueryClient restores successful queries from IndexedDB and keeps writing updates (debounced).
import {
createQueryClient,
persistQueryClient,
useQuery,
} from 'fetchpro-js'
const client = createQueryClient()
// Always await BEFORE creating useQuery observers
const persistence = await persistQueryClient({
client,
dbName: 'fetchpro-js', // IndexedDB database name
storeName: 'query-cache', // object store name
maxAge: 1000 * 60 * 60 * 24, // ignore entries older than 24h (default)
debounceMs: 1000, // wait 1s before writing (default)
buster: 'v1', // bump to wipe incompatible old data
})
const todos = useQuery(
{
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
staleTime: 60_000,
},
client
)
// After refresh: getState() may already have data from IndexedDB.
// If still within staleTime, no network request runs.What gets stored?
Only successful query entries: queryKey, data, and dataUpdatedAt.
Errors and in-flight state are not persisted. dataUpdatedAt is restored so staleTime behaves correctly after reload.
Persistence helpers
await persistence.persist() // flush cache to IndexedDB now
await persistence.clearPersisted() // delete IndexedDB snapshot only
persistence.unsubscribe() // stop auto-syncFull page bootstrap example
async function main() {
const client = createQueryClient()
await persistQueryClient({ client, buster: 'v1' })
const todos = useQuery(
{
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
staleTime: 60_000,
},
client
)
todos.subscribe(() => {
const { data, isLoading, isFetching } = todos.getState()
document.body.dataset.state = isLoading
? 'loading'
: isFetching
? 'fetching'
: 'ready'
document.getElementById('app')!.textContent = JSON.stringify(data ?? null)
})
}
main().catch(console.error)API reference
createQueryClient() / QueryClient
| Method | Description |
|--------|-------------|
| getQueryData(key) | Read cached data |
| setQueryData(key, data) | Write cached data |
| dehydrate() | Snapshot successful queries |
| hydrate(state) | Restore a snapshot into memory |
| invalidateQueries(key, { exact? }) | Stale + refetch active observers |
| clear() | Clear in-memory cache |
| getQueryCache() | Access the underlying cache |
persistQueryClient(options)
Returns { unsubscribe, persist, clearPersisted } after restoring from IndexedDB.
createIndexedDBPersister(options?)
Low-level persister if you want a custom persistQueryClient({ persister }) setup.
Defaults cheat sheet
| Setting | Default |
|---------|---------|
| staleTime | 0 (always stale → refetch when observer mounts if needed) |
| enabled | true |
| retry | 1 |
| Invalidate match | prefix (use { exact: true } for exact) |
| Persist maxAge | 24 hours |
| Persist debounceMs | 1000 |
License
ISC
