npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

fetchpro-js

v1.0.1

Published

Framework-free query client with caching, useQuery, useMutation, invalidateQueries, and IndexedDB persistence

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, refetch
  • useMutation — 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-js

Works 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:

  1. Create a QueryClient
  2. (Optional) Restore from IndexedDB with persistQueryClient
  3. Create observers with useQuery / useMutation
  4. subscribe → read getState() → update UI
  5. 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 success

useMutation — 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-sync

Full 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