atom

Signature
atom<T>(defaultValue: T, options?: AtomOptions): Atom<T>

valdres Creates a reactive piece of state

An atom holds a single value that can be read and written from any component or outside of React entirely.

Usage

import { atom } from "valdres"

// Simple atom with a default value
const countAtom = atom(0)

// Atom with an async initializer
const userAtom = atom(() =>
    fetch("/api/user").then(res => res.json())
)

// With a label for debugging
const nameAtom = atom("default", { label: "nameAtom" })

Parameters

ParameterTypeDescription
defaultValueT | () => T | Promise<T>Initial value, or a function that returns one
options.labelstringOptional label for debugging and devtools
options.maxAgeReactive<number>Re-fetch interval in milliseconds (requires async default). Accepts a number, atom, or selector.
options.staleWhileRevalidateReactive<number>Serve stale data for this many ms while re-fetching. Accepts a number, atom, or selector.
options.staleIfErrorReactive<number>Serve stale data for this many ms after re-fetch errors. Accepts a number, atom, or selector.

Async atoms

Tip
When you pass a function as the default value, it becomes an async atom. The function is called lazily — only when the atom is first read.

In React, async atoms work seamlessly with Suspense:

const dataAtom = atom(async () => {
    const res = await fetch("/api/data")
    return res.json()
})

Caching & revalidation

Async atoms support built-in stale-while-revalidate caching via maxAge, staleWhileRevalidate, and staleIfError. This is useful for data that should periodically refresh from an API while keeping the UI responsive.

maxAge

Sets how often the atom re-fetches its data (in milliseconds). Revalidation only happens while the atom has active subscribers.

const pricesAtom = atom(
    async () => {
        const res = await fetch("/api/prices")
        return res.json()
    },
    { maxAge: 30_000 }, // Re-fetch every 30 seconds
)

When maxAge expires, the atom calls its default function again. Without staleWhileRevalidate, the atom immediately enters a loading state (returns a pending promise), which triggers Suspense in React.

staleWhileRevalidate

Keeps serving the previous value while the re-fetch is in progress, instead of showing a loading state. The value updates seamlessly once the new data resolves.

const pricesAtom = atom(
    async () => {
        const res = await fetch("/api/prices")
        return res.json()
    },
    {
        maxAge: 30_000,             // Re-fetch every 30s
        staleWhileRevalidate: 60_000, // Serve stale data for up to 60s while fetching
    },
)

This means your UI never shows a loading spinner on re-fetches — users see the previous data until the fresh data arrives.

staleIfError

Extends the stale window when re-fetches fail. Instead of surfacing an error to the UI, the atom keeps serving the last successful value for the specified duration.

const pricesAtom = atom(
    async () => {
        const res = await fetch("/api/prices")
        if (!res.ok) throw new Error("API error")
        return res.json()
    },
    {
        maxAge: 30_000,
        staleWhileRevalidate: 60_000,
        staleIfError: 300_000, // Keep stale data for up to 5 minutes on errors
    },
)

If the re-fetch fails within the staleIfError window (measured from the last successful fetch), the previous value is preserved. Once the window expires, the rejected promise is surfaced so error boundaries can handle it.

Example: API response with caching

import { atom } from "valdres"

type User = { id: string; name: string; email: string }

const currentUserAtom = atom<User>(
    async () => {
        const res = await fetch("/api/me", {
            headers: { Authorization: `Bearer ${getToken()}` },
        })
        if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
        return res.json()
    },
    {
        maxAge: 60_000,               // Refresh every minute
        staleWhileRevalidate: 120_000, // Show stale for 2 min while fetching
        staleIfError: 300_000,         // Tolerate errors for 5 min
    },
)

Timeline of behavior:

TimeEventWhat the UI sees
0sFirst read, fetch startsSuspense loading state
~200msFetch resolvesUser data
60smaxAge expires, re-fetch startsPrevious user data (SWR)
~60.2sRe-fetch resolvesUpdated user data
120smaxAge expires, re-fetch failsPrevious user data (staleIfError)
180smaxAge expires, re-fetch succeedsFresh user data

Note
Revalidation is only active while the atom has subscribers. When all components unsubscribe, the interval is cleared. The next subscription triggers a fresh fetch.

Reactive cache configuration

Cache options can be dynamic by passing an atom or selector instead of a static number. When the config value changes, the revalidation interval automatically restarts with the new timing.

import { atom, selector } from "valdres"

const refreshIntervalAtom = atom(30_000)

const pricesAtom = atom(
    async () => {
        const res = await fetch("/api/prices")
        return res.json()
    },
    { maxAge: refreshIntervalAtom },
)

// Changing the interval updates the revalidation schedule
store.set(refreshIntervalAtom, 5_000) // now refreshes every 5 seconds

You can also use a selector to derive the interval from other state:

const realtimeModeAtom = atom(false)

const maxAgeSelector = selector(get =>
    get(realtimeModeAtom) ? 1_000 : 30_000
)

const dataAtom = atom(
    async () => fetch("/api/data").then(r => r.json()),
    { maxAge: maxAgeSelector },
)

cacheMeta

Use cacheMeta() to get a reactive selector that exposes the caching state of an atom. This is useful for showing loading indicators during revalidation or displaying when data was last refreshed.

import { atom, cacheMeta } from "valdres"

const dataAtom = atom(
    async () => fetch("/api/data").then(r => r.json()),
    { maxAge: 30_000, staleWhileRevalidate: 60_000 },
)

const meta = store.get(cacheMeta(dataAtom))
// {
//   isRevalidating: false,
//   lastSuccessAt: 1713100800000,
//   maxAge: 30000,
//   staleWhileRevalidate: 60000,
//   staleIfError: undefined,
// }

cacheMeta() returns a selector, so you can subscribe to it reactively:

store.sub(cacheMeta(dataAtom), meta => {
    console.log(meta.isRevalidating) // true when re-fetching
    console.log(meta.lastSuccessAt)  // timestamp of last success
})

Returns null for atoms that don't have maxAge configured.

See also

  • atomFamily — create a collection of atoms keyed by a parameter
  • selector — derive computed state from atoms
  • watch — read and write an atom in your components