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

state-resource

v0.7.6

Published

Async state resource management

Readme

state-resource

Promise caching, deduplication, invalidation, and shared query state updates with a React hook.

No context, no provider, no boilerplate.

npm i state-resource
# or
bun add state-resource

React is an optional peer dependency. The core cache (createQuery / invalidate) works in any environment.


Highlights

  • Promise-level request deduplication by argument key
  • Stale-while-revalidate behavior in useQuery
  • Per-entry refresh and global invalidation
  • Shared state mutation via useQuery(...).setData(...)
  • Fully typed APIs (arguments and data inferred end-to-end)
  • Works without React for cache-only use cases

Quick start

// queries.ts
import { createQuery } from 'state-resource'

export const userQuery = createQuery('users', (id: number) =>
  fetch(`/api/users/${id}`).then(r => r.json())
)
// UserCard.tsx
import { useQuery } from 'state-resource'
import { userQuery } from './queries'

function UserCard({ userId }: { userId: number }) {
  const user = useQuery(userQuery, [userId])

  if (user.status === 'loading') return <Spinner />
  if (user.status === 'error')   return <p>Error: {user.error.message}</p>

  return (
    <div>
      <p>{user.data.name}</p>
      <button onClick={user.refresh}>Refresh</button>
    </div>
  )
}

API

createQuery(cacheId, fn)

Creates a typed cached query function.

const userQuery = createQuery('users', async (id: number) => {
  const res = await fetch(`/api/users/${id}`)
  return res.json() as Promise<User>
})

| Param | Type | Description | | --------- | -------------------------------- | ---------------------------------- | | cacheId | string | Unique key for this query's cache | | fn | (...args: A) => Promise<R> | Async function that fetches data |

Returns a Query<A, R> — a callable function with extra methods:

| Member | Description | | --- | --- | | query(...args) | Returns cached promise for args, or starts a new fetch | | query.invalidate(...args) | Refetches one cache entry and notifies listeners for that key | | query.clear() | Clears all cached promises, refetchers, and snapshots for this cacheId | | query.cacheId | Read-only cache ID |

Caching is argument-based. The same arguments always return the same promise. Object argument key order is normalized, so { a: 1, b: 2 } and { b: 2, a: 1 } hit the same cache entry.

userQuery(1) === userQuery(1)  // true — same promise, only one fetch
userQuery(1) !== userQuery(2)  // different args → different entries

Failed promises are evicted automatically, so the next call re-fetches cleanly without manual cleanup.


invalidate(target)

Global invalidation: refetches every cached entry currently known for a query.

import { invalidate } from 'state-resource'

// by cacheId string
invalidate('users')

// by query object (same result)
invalidate(userQuery)

Use this after a mutation that affects multiple entries at once.


useQuery(query, args)

React hook for subscribing to one query entry.

const result = useQuery(userQuery, [userId])

Returns QueryResult<T>:

type QueryResult<T> =
  | {
      status: 'loading'
      data?: T
      error?: undefined
      refresh: () => void
      setData: (next: T | ((prev: T | undefined) => T)) => T
    }
  | {
      status: 'ok'
      data: T
      error?: undefined
      refresh: () => void
      setData: (next: T | ((prev: T | undefined) => T)) => T
    }
  | {
      status: 'error'
      data?: T
      error: Error
      refresh: () => void
      setData: (next: T | ((prev: T | undefined) => T)) => T
    }
  • status: 'loading' — fetch in progress. data carries the previous value while reloading (stale-while-revalidate).
  • status: 'ok' — fetch succeeded.
  • status: 'error' — fetch failed. Failed promise is evicted from cache so retry is clean.
  • refresh() — refetches this exact cache key. Reference is stable while args are stable.
  • setData(next | updater) — updates shared state for this key and re-renders all subscribers using the same query+args key.

setData behavior

  • Accepts either a direct value or updater function
  • Updater receives the previous data (T | undefined)
  • Writes through to the query cache (future reads for the same key resolve to the updated value)
  • Notifies subscribers for the key immediately
function UpvoteButton({ postId }: { postId: number }) {
  const post = useQuery(postQuery, [postId])

  return (
    <button
      disabled={post.status !== 'ok'}
      onClick={() => post.setData(prev => ({ ...(prev ?? { id: postId, votes: 0 }), votes: (prev?.votes ?? 0) + 1 }))}
    >
      +1 vote
    </button>
  )
}

Examples

1 · Cache deduplication

Two components call the same query with the same args. Only one HTTP request fires — both share the cached promise.

function Page() {
  return (
    <>
      <UserBadge userId={1} />  {/* fetches userQuery(1) */}
      <UserAvatar userId={1} /> {/* cache hit — no second request */}
    </>
  )
}

function UserBadge({ userId }: { userId: number }) {
  const user = useQuery(userQuery, [userId])
  if (user.status !== 'ok') return null
  return <span>{user.data.name}</span>
}

function UserAvatar({ userId }: { userId: number }) {
  const user = useQuery(userQuery, [userId])
  if (user.status !== 'ok') return null
  return <img src={user.data.avatarUrl} alt={user.data.name} />
}

2 · Per-entry invalidation

Refresh a single user without touching any other cached users.

function UserCard({ userId }: { userId: number }) {
  const user = useQuery(userQuery, [userId])

  return (
    <div>
      {user.status === 'loading' && <Spinner />}
      {user.status === 'ok' && <p>{user.data.name} — {user.data.role}</p>}
      {user.status === 'error' && <p>Error: {user.error.message}</p>}

      <button onClick={user.refresh} disabled={user.status === 'loading'}>
        Refresh
      </button>
    </div>
  )
}

user.refresh() calls userQuery.invalidate(userId) internally — only that one cache slot is updated. Other UserCards with different userIds are unaffected.


3 · Global invalidation after mutation

After saving a record, invalidate the whole query so every component refetches.

async function saveUser(user: User) {
  await fetch('/api/users', { method: 'POST', body: JSON.stringify(user) })

  // Refetch all cached users in every mounted component at once
  invalidate(userQuery)
}

4 · Shared optimistic updates with setData

All components subscribed to the same key update immediately.

function UserNameEditor({ userId }: { userId: number }) {
  const user = useQuery(userQuery, [userId])

  const rename = async (name: string) => {
    if (user.status !== 'ok') return

    // optimistic UI
    user.setData({ ...user.data, name })

    try {
      await fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        body: JSON.stringify({ name }),
      })
      user.refresh() // reconcile with server
    } catch {
      user.refresh() // rollback by refetching
    }
  }

  return <button onClick={() => rename('New Name')}>Rename</button>
}

5 · Multi-argument queries

Arguments are spread, not wrapped — the type system enforces them at the call site.

const postsQuery = createQuery(
  'posts',
  (userId: number, page: number) =>
    fetch(`/api/users/${userId}/posts?page=${page}`).then(r => r.json())
)

// In a component:
const posts = useQuery(postsQuery, [userId, page])

6 · Object arguments (stable key)

Object key order is normalized, so re-renders with structurally equal objects don't trigger a new fetch.

const searchQuery = createQuery('search', (filter: { q: string; page: number }) =>
  fetch(`/api/search?q=${filter.q}&page=${filter.page}`).then(r => r.json())
)

// These are the same cache entry:
searchQuery({ q: 'hello', page: 1 })
searchQuery({ page: 1, q: 'hello' })
function SearchResults() {
  const [filter, setFilter] = useState({ q: '', page: 1 })
  const results = useQuery(searchQuery, [filter])

  return (
    <>
      <input
        value={filter.q}
        onChange={e => setFilter(f => ({ ...f, q: e.target.value, page: 1 }))}
      />
      {results.status === 'loading' && <Spinner />}
      {results.status === 'ok' && results.data.map(r => <p key={r.id}>{r.title}</p>)}
    </>
  )
}

7 · Dependent queries

Load a user, then load their posts only once the userId is known. Switching back to a previously selected user is instant — both caches are warm.

function UserDashboard() {
  const [userId, setUserId] = useState(1)
  const user = useQuery(userQuery, [userId])

  return (
    <div>
      <UserSelector onChange={setUserId} />
      {user.status === 'ok' && (
        <>
          <UserProfile user={user.data} />
          <PostList userId={userId} />
        </>
      )}
    </div>
  )
}

function PostList({ userId }: { userId: number }) {
  const posts = useQuery(postsQuery, [userId])
  if (posts.status === 'loading') return <Spinner />
  if (posts.status === 'error') return <p>Failed to load posts</p>
  return (
    <ul>
      {posts.data.map(p => <li key={p.id}>{p.title}</li>)}
    </ul>
  )
}

8 · Error recovery

Failed fetches are evicted from the cache automatically. Calling refresh() or changing args re-fetches cleanly.

function WeatherCard({ city }: { city: string }) {
  const weather = useQuery(weatherQuery, [city])

  return (
    <div>
      {weather.status === 'loading' && <Spinner />}
      {weather.status === 'ok' && <p>{weather.data.temp}°C — {weather.data.condition}</p>}
      {weather.status === 'error' && (
        <>
          <p>Error: {weather.error.message}</p>
          <button onClick={weather.refresh}>Retry</button>
        </>
      )}
    </div>
  )
}

9 · Using the cache outside React

createQuery and invalidate are plain functions — no React required.

import { createQuery, invalidate } from 'state-resource'

const configQuery = createQuery('config', () =>
  fetch('/api/config').then(r => r.json())
)

// Warm the cache at app startup
await configQuery()

// Later, after config changes on the server:
invalidate(configQuery)

10 · Clearing the cache

Use query.clear() to wipe all entries — useful on logout or when switching accounts.

async function logout() {
  await fetch('/api/logout', { method: 'POST' })

  // Purge all cached data
  userQuery.clear()
  postsQuery.clear()
  settingsQuery.clear()
}

11 · Manual filter on already-fetched data

You can derive local view state from remote data without extra context stores.

function FilteredUsers() {
  const [search, setSearch] = useState('')
  const users = useQuery(usersQuery, [{ search: '' }])

  const applyLocalFilter = () => {
    const needle = search.toLowerCase().trim()
    users.setData(prev => (prev ?? []).filter(user => user.name.toLowerCase().includes(needle)))
  }

  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <button onClick={applyLocalFilter}>Filter Local Data</button>
      <button onClick={users.refresh}>Refetch</button>
    </div>
  )
}

TypeScript

Fully typed. Arguments and return types flow through automatically.

const userQuery = createQuery('users', async (id: number) => {
  const res = await fetch(`/api/users/${id}`)
  return res.json() as Promise<{ id: number; name: string }>
})

// TypeScript knows: data is { id: number; name: string }
const user = useQuery(userQuery, [1])
if (user.status === 'ok') {
  console.log(user.data.name) // ✓
}

// TypeScript enforces args — this won't compile:
useQuery(userQuery, ['not-a-number']) // ✗ Argument of type 'string' is not assignable to type 'number'

Exports

import {
  createQuery,
  invalidate,
  useQuery,
  type Query,
  type QueryState,
  type Snapshot,
} from 'state-resource'

How it works

  • Promise cache: Map<cacheId, Map<argsKey, Promise<R>>>
  • Snapshots: Map<cacheId, Map<argsKey, Snapshot<R>>> tracks loading | ok | error plus optional stale data
  • Listeners: subscribers are stored per cacheId + argsKey
  • Stable keys: generated via stableKey(args) with deterministic object-key ordering
  • Fetch flow:
    • set loading snapshot
    • run fetch
    • set ok or error snapshot
    • notify listeners for that key
  • Error eviction: on rejected fetch, promise cache entry is removed for clean retries
  • React integration: useSyncExternalStore subscribes components to key-level snapshot updates
  • Shared mutation: setData writes snapshot + cache and broadcasts key updates immediately

Best practices

  • Use descriptive cacheId values (users, posts, weather) and keep them stable.
  • Keep args serializable and deterministic.
  • Use refresh() for entry-level revalidation.
  • Use invalidate(queryOrCacheId) after broad mutations.
  • Use setData for optimistic updates and local derivations, then refresh() to reconcile with server data when needed.
  • Call query.clear() on logout/account switch to avoid cross-user stale data.

License

MIT