PraxisJS

Async Data

@Resource is PraxisJS's decorator for binding async data to a component field — it tracks loading, error, and data state reactively, with automatic refetch, cancel, mutate, shared cache, and stale-while-revalidate.

Async Data

@Resource binds an async data source directly to a component field. It handles loading state, error handling, and automatic refetching when its reactive dependencies change — without any manual wiring.

npm install @praxisjs/core @praxisjs/decorators
pnpm add @praxisjs/core @praxisjs/decorators
yarn add @praxisjs/core @praxisjs/decorators
bun add @praxisjs/core @praxisjs/decorators

Basic usage

import { Component, State, Resource } from '@praxisjs/decorators'
import type { ResourceInstance } from '@praxisjs/decorators'
import { StatefulComponent } from '@praxisjs/core'

@Component()
class UserProfile extends StatefulComponent {
  @State() userId = 1

  @Resource((self: UserProfile) =>
    fetch(`/api/users/${self.userId}`).then(r => r.json() as Promise<User>)
  )
  user!: ResourceInstance<User>

  render() {
    return (
      <div>
        {() => this.user.pending() && <p>Loading...</p>}
        {() => this.user.error() && <p>Error: {this.user.error()?.message}</p>}
        {() => this.user.data() && <p>Hello, {this.user.data()!.name}</p>}
      </div>
    )
  }
}

When self.userId changes, the resource automatically cancels any pending request and starts a new one. The userId signal is read inside the fetcher, making it a tracked reactive dependency.

For fetchers with no component dependency, the short arrow form works without self:

@Resource(() => fetch('/api/config').then(r => r.json() as Promise<Config>))
config!: ResourceInstance<Config>
Storybook
Live demo — pagination + keepPreviousData

Resource state

PropertyTypeDescription
.data()T | nullThe resolved value, or null before the first successful fetch
.pending()booleantrue while a fetch is in-flight
.error()Error | nullThe last error, or null if no error
.status()'idle' | 'pending' | 'success' | 'error'Full status string

All properties are reactive — read them inside {() => ...} in JSX to subscribe.


Options

@Resource(() => api.getUsers(), {
  initialData: [],        // value of .data() before the first fetch completes
  immediate: false,       // don't fetch automatically on init (default: true)
  keepPreviousData: true, // keep old data visible while refetching
  key: 'users',           // cache key — enables SWR, deduplication, and invalidation
  staleTime: 5000,        // ms before cached data is considered stale (default: 0)
  refetchOnFocus: true,   // refetch when the tab/window regains focus
})
users!: ResourceInstance<User[]>

keepPreviousData is useful for pagination: the current page stays visible while the next one loads.

Storybook
Live demo — immediate: false

Refetch, cancel, and mutate

// Manually trigger a new fetch
<button onClick={() => this.user.refetch()}>Refresh</button>

// Cancel an in-flight request (the promise is abandoned, not aborted)
<button onClick={() => this.user.cancel()}>Cancel</button>

Optimistic updates

mutate() lets you update the UI immediately while the server request is in-flight:

async save(newName: string) {
  // Update the UI now
  this.user.mutate({ ...this.user.data()!, name: newName })

  // Sync with the server, then confirm with a fresh fetch
  await api.updateUser({ name: newName })
  this.user.refetch()
}

Shared cache and stale-while-revalidate

Setting a key enables three behaviors at once: a shared cache, deduplication, and stale-while-revalidate (SWR).

Stale-while-revalidate

When a resource with a key mounts and the cache already has data, it renders the cached (stale) value immediately and fetches fresh data in the background. The UI is never blank:

// First component to mount fetches and fills the cache
@Resource(() => api.getConfig(), { key: 'app-config' })
config!: ResourceInstance<AppConfig>

// Second component with the same key shows cached data instantly,
// then silently updates when the background fetch completes
@Resource(() => api.getConfig(), { key: 'app-config' })
config!: ResourceInstance<AppConfig>

staleTime

staleTime (in milliseconds) controls how long cached data is considered fresh. While fresh, the initial fetch is skipped entirely:

@Resource(() => api.getConfig(), {
  key: 'app-config',
  staleTime: 60_000, // fresh for 60 s — no fetch if cache is younger
})
config!: ResourceInstance<AppConfig>
staleTimeBehaviour
0 (default)Always stale — shows cached value, fetches in background (SWR)
5000Fresh for 5 s — no fetch if the cache is newer than 5 s
InfinityNever refetch from cache — static until explicitly invalidated

Deduplication

When multiple component instances share the same key and all try to fetch at the same time, only one network request is made. All instances attach to the same in-flight promise:

// Three instances on the same page — one fetch, shared result
@Resource(() => api.getCurrentUser(), { key: 'current-user' })
user!: ResourceInstance<User>

key is designed for fetchers with no reactive dependencies (e.g. global config, current user). For reactive per-instance fetches where the fetcher reads self.someSignal, rely on PraxisJS's automatic dep tracking instead — key does not support dynamic values computed at runtime.

Storybook
Live demo — stale-while-revalidate
Storybook
Live demo — deduplication

Invalidating by key

invalidateResource(key) clears the cache entry and triggers an immediate refetch on every active resource registered under that key — from anywhere in the app:

import { invalidateResource } from '@praxisjs/decorators'

// After a mutation, refresh all components showing the current user
async function updateProfile(data: ProfileUpdate) {
  await api.updateProfile(data)
  invalidateResource('current-user')
}
@Component()
class ProfileCard extends StatefulComponent {
  @Resource(() => api.getCurrentUser(), { key: 'current-user' })
  user!: ResourceInstance<User>

  // Automatically refetches when invalidateResource('current-user') is called
}
Storybook
Live demo — invalidateResource()

Refetch on focus

When refetchOnFocus: true, the resource automatically refetches whenever the browser tab or window becomes visible again. This keeps data fresh after the user switches tabs:

@Resource(() => api.getNotifications(), {
  key: 'notifications',
  refetchOnFocus: true,
})
notifications!: ResourceInstance<Notification[]>

refetchOnFocus uses document.visibilitychange and has no effect in non-browser environments (SSR, Node.js tests).

Storybook
Live demo — refetchOnFocus

Reactive dependencies

Any signal read inside the fetcher is a tracked dependency. When it changes, the resource automatically cancels any pending request and starts a new one:

@State() page = 1
@State() search = ''

@Resource((self: SearchPage) =>
  fetch(`/api/search?q=${self.search}&page=${self.page}`).then(r => r.json())
)
results!: ResourceInstance<SearchResult[]>

Changing self.page or self.search triggers an immediate refetch because both signals are read inside the fetcher.


What's next?

On this page