pyreon

@pyreon/query is the Pyreon adapter for TanStack Query. It wraps TanStack Query's observer pattern with Pyreon's fine-grained reactivity signals, so each property (data, error, isPending, etc.) is an independent signal that only triggers updates for the effects that depend on it.

@pyreon/querystable

Installation

npm install @pyreon/query
bun add @pyreon/query
pnpm add @pyreon/query
yarn add @pyreon/query

TanStack Query core is included as a dependency -- you do not need to install @tanstack/query-core separately.

How It Works

@tanstack/react-query's reactive granularity is the observer = the whole component. It is field-aware across components (its tracked-properties optimization skips a re-render when none of a component's accessed fields changed), but within a single component, a change to any accessed field re-renders the entire component — re-running every field derivation in its body plus a VDOM reconcile. Pyreon's granularity is the signal: each field (data, error, isPending, isFetching, etc.) is an independent signal, and batch() coalesces the subscribe callback's writes into one flush. Only the bindings that read the field that actually changed re-run. This means:

  • A component that only reads query.data() will not re-run when isFetching flips. (react-query matches this across components — see the benchmark below.)

  • Within one component that reads several fields, flipping data re-runs only the data binding — the status / isFetching / error bindings do not re-execute (react-query re-runs the whole component body).

  • A loading spinner that only reads query.isPending() will not re-run when data arrives (it will re-run once to switch from pending to not-pending, but not for the data change itself).

  • The result signal provides the full observer result for cases where you need multiple fields in one read.

Benchmark — fine-grained vs @tanstack/react-query

Both adapters wrap the same @tanstack/query-core (pinned to one version tree-wide), so the comparison is fair by construction — it measures the adapter layer, not the query engine. Run it with bun run --filter=@pyreon/query bench:react-query (NODE_ENV=production, real react-dom@19 in happy-dom, deterministic COUNTS + process-isolated timing). On a data-only change to a query (via setQueryData, so status / isFetching / error stay put):

ScenarioPyreon@tanstack/react-query
Cross-component — one component reads only status, one only data → flip datastatus skips, data re-runs (1)status skips, data re-renders (1) — 🤝 tie (tracked-props)
Intra-component — one component reads all 8 fields → flip data0 component re-runs · 1 field-derivation1 whole-component re-render · 8 field-derivations + VDOM reconcile
Time to reflect the change in the DOMsynchronous fine-grained patchmacrotask-batched render + reconcile — Pyreon faster; see Benchmarks for current measurements
Mount a 1-query component → first DOM🤝 tie🤝 tie

The honest reads: react-query is not naive — its tracked-props make it field-aware across components (the cross-component row is a genuine tie). Pyreon's structural win is within a component (fine-grained signals vs whole-component re-render) and steady-state per-update latency (synchronous fine-grained patch vs macrotask-batched VDOM reconcile). Mounting is comparable. (See Benchmarks for current measurements; this suite runs in happy-dom — a JavaScript DOM, not a browser — so the counts and ratios are the signal, not absolute times.)

Lazy signal allocation

Each useQuery / useMutation / useSuspenseQuery / useInfiniteQuery exposes 8-13 fine-grained Signal<T> fields, but real consumers typically read 1-2. The hooks lazy-allocate each signal on first property access via a slot-bag (slots.data ??= signal(...)); the subscribe callback only writes to materialized slots. Same Signal<T> identity is preserved across repeat property access (load-bearing for Pyreon's identity-keyed effect tracking).

Real-world impact (5-run median on the perf-dashboard stress journeys):

JourneysignalCreatesignalWritewall-clock
queryMount-10009000 → 010006 → 6 (-99.9%)-15%
queryNotify-10kn/a20070 → 7 (-100%)-22%
mutationInvalidate-1000908 → 0unchangedheap -25.8%

The journey numbers are the EXTREME case (the stress component reads ZERO result fields → zero materialization). Real apps reading 1-3 fields land in the 67-89% range. Reference: PR #492.

Setup

QueryClientProvider

Wrap your app with QueryClientProvider to provide a QueryClient to all descendant components.

// @check
import { defineComponent } from '@pyreon/core'
import { mount } from '@pyreon/runtime-dom'
import { QueryClient, QueryClientProvider } from '@pyreon/query'

declare function MyApp(): JSX.Element

const queryClient = new QueryClient()

const App = defineComponent(() => {
  return () => (
    <QueryClientProvider client={queryClient}>
      <MyApp />
    </QueryClientProvider>
  )
})

mount(<App />, document.getElementById('app')!)

QueryClientProvider internally:

  1. Provides the QueryClient to its subtree via provide(), so all descendants can read it with useQueryClient().

  2. Calls client.mount() on mount -- this activates window focus refetching and online/offline handling.

  3. Calls client.unmount() on unmount -- this unsubscribes focusManager and onlineManager.

QueryClient Configuration

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      gcTime: 10 * 60 * 1000, // 10 minutes (garbage collection)
      retry: 3, // retry failed queries 3 times
      retryDelay: (
        attempt, // exponential backoff
      ) => Math.min(1000 * 2 ** attempt, 30000),
      refetchOnWindowFocus: true, // refetch when tab regains focus
      refetchOnReconnect: true, // refetch when network reconnects
      refetchOnMount: true, // refetch when component mounts
    },
    mutations: {
      retry: 0, // don't retry mutations by default
    },
  },
})

useQueryClient

Returns the nearest QueryClient from context. Throws if called outside a QueryClientProvider.

import { useQueryClient } from '@pyreon/query'

function MyComponent() {
  const client = useQueryClient()

  // Use client for imperative operations:
  client.invalidateQueries({ queryKey: ['posts'] })
  client.prefetchQuery({ queryKey: ['user', 1], queryFn: fetchUser })
  client.setQueryData(['user', 1], updatedUser)
  client.getQueryData(['user', 1])
  client.removeQueries({ queryKey: ['stale-data'] })

  // ...
}

Queries

useQuery

Subscribe to a query. Returns fine-grained reactive signals for data, error, and status.

Options are passed as a function so reactive signals (e.g., a signal-based query key) can be read inside. When a signal changes, the observer updates and refetches automatically.

import { useQuery } from '@pyreon/query'
import { signal } from '@pyreon/reactivity'

const userId = signal(1)

const query = useQuery(() => ({
  queryKey: ['user', userId()],
  queryFn: () => fetch(`/api/users/${userId()}`).then((r) => r.json()),
}))
useQuery — fetch + cache by key

UseQueryResult

SignalTypeDescription
dataSignal<TData | undefined>The resolved data
errorSignal<TError | null>The error, if any
statusSignal<'pending' | 'error' | 'success'>Current status
isPendingSignal<boolean>No data yet (initial load)
isLoadingSignal<boolean>First fetch in progress
isFetchingSignal<boolean>Any fetch in progress (including background)
isErrorSignal<boolean>Query has errored
isSuccessSignal<boolean>Query has data
resultSignal<QueryObserverResult>Full observer result
MethodDescription
refetch()Manually trigger a refetch. Returns a promise.

All useQuery Options

The options function receives all TanStack Query options. Here are the most commonly used:

const query = useQuery(() => ({
  // Required
  queryKey: ['user', userId()], // Unique cache key (array)
  queryFn: (
    { signal }, // Fetcher function
  ) => fetch(`/api/users/${userId()}`, { signal }).then((r) => r.json()),

  // Timing
  staleTime: 5 * 60 * 1000, // Data considered fresh for 5 minutes
  gcTime: 10 * 60 * 1000, // Keep unused data in cache for 10 minutes
  refetchInterval: 30_000, // Poll every 30 seconds
  refetchIntervalInBackground: false, // Stop polling when tab is hidden

  // Behavior
  enabled: true, // Set to false to disable auto-fetching
  retry: 3, // Number of retries on failure
  retryDelay: (
    attempt, // Custom retry delay
  ) => Math.min(1000 * 2 ** attempt, 30000),
  refetchOnWindowFocus: true, // Refetch when tab regains focus
  refetchOnReconnect: true, // Refetch when network reconnects
  refetchOnMount: true, // Refetch when component mounts

  // Data transformation
  select: (data) => data.user, // Transform/select from cached data

  // Placeholder
  placeholderData: previousData, // Show while fetching
  initialData: cachedUser, // Seed the cache immediately
  initialDataUpdatedAt: Date.now(), // When initialData was last fetched
}))

Basic Component Example

const UserProfile = defineComponent(() => {
  const query = useQuery(() => ({
    queryKey: ['user', 1],
    queryFn: () => fetch('/api/users/1').then((r) => r.json()),
  }))

  return () => {
    if (query.isPending()) return <p>Loading...</p>
    if (query.isError()) return <p>Error: {String(query.error())}</p>
    return <h1>{query.data()?.name}</h1>
  }
})

Reactive Query Keys

When signals are used inside the options function, the query automatically refetches when those signals change:

const SearchResults = defineComponent(() => {
  const searchTerm = signal('')
  const category = signal('all')
  const page = signal(1)

  const query = useQuery(() => ({
    queryKey: ['search', searchTerm(), category(), page()],
    queryFn: () =>
      fetch(`/api/search?q=${searchTerm()}&cat=${category()}&page=${page()}`).then((r) => r.json()),
    // Don't search until user types something
    enabled: searchTerm().length > 0,
    // Keep showing old results while new ones load
    placeholderData: (prev: SearchResult | undefined) => prev,
    staleTime: 60_000, // Results are fresh for 1 minute
  }))

  return () => (
    <div>
      <input
        type="text"
        placeholder="Search..."
        onInput={(e) => {
          searchTerm.set(e.currentTarget.value)
          page.set(1) // Reset to page 1 on new search
        }}
      />
      <select onChange={(e) => category.set(e.currentTarget.value)}>
        <option value="all">All</option>
        <option value="posts">Posts</option>
        <option value="users">Users</option>
      </select>
      {() => {
        if (query.isPending()) return <Spinner />
        if (query.isError()) return <ErrorMessage error={query.error()} />
        return (
          <div>
            <ResultsList results={query.data()?.items} />
            <Pagination
              page={() => page()}
              total={query.data()?.totalPages}
              onPageChange={(p: number) => page.set(p)}
            />
          </div>
        )
      }}
    </div>
  )
})

Conditional/Enabled Queries

const UserPosts = defineComponent((props: { userId: () => number | null }) => {
  const query = useQuery(() => ({
    queryKey: ['posts', props.userId()],
    queryFn: () => fetch(`/api/users/${props.userId()}/posts`).then((r) => r.json()),
    // Only fetch when userId is available
    enabled: props.userId() !== null,
  }))

  return () => {
    if (!props.userId()) return <p>Select a user to see their posts</p>
    if (query.isPending()) return <Spinner />
    return <PostList posts={query.data()} />
  }
})

Data Transformation with select

const query = useQuery(() => ({
  queryKey: ['users'],
  queryFn: () => fetch('/api/users').then((r) => r.json()),
  // Only subscribe to the names -- other data changes won't trigger updates
  select: (data: User[]) => data.map((u) => u.name),
}))

// query.data() is Signal<string[] | undefined>

Query Cancellation

TanStack Query passes an AbortSignal to your query function. Pass it to fetch to support automatic cancellation when the query key changes or the component unmounts:

const query = useQuery(() => ({
  queryKey: ['search', term()],
  queryFn: ({ signal }) => fetch(`/api/search?q=${term()}`, { signal }).then((r) => r.json()),
}))

When term() changes, the previous fetch is automatically aborted before the new one starts.

Mutations

useMutation

Run a mutation (create, update, delete). Returns reactive signals for state plus mutate and mutateAsync functions.

import { useMutation, useQueryClient } from '@pyreon/query'

const client = useQueryClient()

const mutation = useMutation({
  mutationFn: (data: { title: string }) =>
    fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(data),
    }).then((r) => r.json()),
  onSuccess: () => {
    client.invalidateQueries({ queryKey: ['posts'] })
  },
})

// Fire and forget (errors captured in signal)
mutation.mutate({ title: 'New Post' })

// Or await the result
try {
  const result = await mutation.mutateAsync({ title: 'New Post' })
} catch (err) {
  // handle error
}

UseMutationResult

SignalTypeDescription
dataSignal<TData | undefined>The mutation result data
errorSignal<TError | null>The error, if any
statusSignal<'idle' | 'pending' | 'success' | 'error'>Current status
isPendingSignal<boolean>Mutation is in flight
isSuccessSignal<boolean>Mutation succeeded
isErrorSignal<boolean>Mutation errored
isIdleSignal<boolean>Mutation hasn't been called yet
MethodDescription
mutate(variables, options?)Fire the mutation. Error is captured in signal, not thrown.
mutateAsync(variables, options?)Fire the mutation and return a promise. Throws on error.
reset()Reset mutation state back to idle.

Mutation Callbacks

const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: (variables) => {
    // Called before the mutation function fires
    console.log('Updating user:', variables)
    // Return context for onError
    return { previousUser: client.getQueryData(['user', variables.id]) }
  },
  onSuccess: (data, variables, context) => {
    // Called on success
    console.log('User updated:', data)
    client.invalidateQueries({ queryKey: ['user', variables.id] })
  },
  onError: (error, variables, context) => {
    // Called on error
    console.error('Update failed:', error)
    // Roll back optimistic update using context from onMutate
    if (context?.previousUser) {
      client.setQueryData(['user', variables.id], context.previousUser)
    }
  },
  onSettled: (data, error, variables, context) => {
    // Called on both success and error
    console.log('Mutation settled')
  },
})

Per-Call Callbacks

You can also pass callbacks to individual mutate calls. These run after the mutation-level callbacks:

mutation.mutate(
  { id: 1, name: 'Updated Name' },
  {
    onSuccess: (data) => {
      // Runs after the mutation-level onSuccess
      showToast('User updated successfully!')
    },
    onError: (error) => {
      showToast(`Update failed: ${error.message}`)
    },
  },
)

Optimistic Updates

Update the UI immediately, then roll back on error:

const UpdateTodo = defineComponent((props: { todo: Todo }) => {
  const client = useQueryClient()

  const mutation = useMutation({
    mutationFn: (update: Partial<Todo>) =>
      fetch(`/api/todos/${props.todo.id}`, {
        method: 'PATCH',
        body: JSON.stringify(update),
      }).then((r) => r.json()),

    onMutate: async (update) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await client.cancelQueries({ queryKey: ['todos'] })

      // Snapshot the previous value
      const previousTodos = client.getQueryData<Todo[]>(['todos'])

      // Optimistically update the cache
      client.setQueryData<Todo[]>(['todos'], (old) =>
        old?.map((t) => (t.id === props.todo.id ? { ...t, ...update } : t)),
      )

      return { previousTodos }
    },

    onError: (_err, _update, context) => {
      // Roll back to the previous value on error
      if (context?.previousTodos) {
        client.setQueryData(['todos'], context.previousTodos)
      }
    },

    onSettled: () => {
      // Refetch to make sure we're in sync with the server
      client.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return () => (
    <div class="todo-item">
      <input
        type="checkbox"
        checked={props.todo.completed}
        onChange={(e) => {
          mutation.mutate({
            completed: e.currentTarget.checked,
          })
        }}
      />
      <span>{props.todo.title}</span>
      {() => mutation.isPending() && <Spinner size="sm" />}
    </div>
  )
})

CRUD Example

function useTodos() {
  const client = useQueryClient()

  const todosQuery = useQuery(() => ({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then((r) => r.json()),
  }))

  const createTodo = useMutation({
    mutationFn: (title: string) =>
      fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, completed: false }),
      }).then((r) => r.json()),
    onSuccess: () => {
      client.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  const updateTodo = useMutation({
    mutationFn: (update: { id: number; completed: boolean }) =>
      fetch(`/api/todos/${update.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed: update.completed }),
      }).then((r) => r.json()),
    onSuccess: () => {
      client.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  const deleteTodo = useMutation({
    mutationFn: (id: number) => fetch(`/api/todos/${id}`, { method: 'DELETE' }),
    onSuccess: () => {
      client.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return { todosQuery, createTodo, updateTodo, deleteTodo }
}

Infinite Queries

useInfiniteQuery

Subscribe to a paginated or infinite-scroll query. Returns all the signals from useQuery plus pagination-specific signals and methods.

import { useInfiniteQuery } from '@pyreon/query'

const query = useInfiniteQuery(() => ({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetch(`/api/posts?cursor=${pageParam}`).then((r) => r.json()),
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
}))

// Access pages
query.data()?.pages // array of page results
query.data()?.pageParams // array of page params

// Pagination controls
query.hasNextPage()
query.hasPreviousPage()
query.fetchNextPage()
query.fetchPreviousPage()
query.isFetchingNextPage()
query.isFetchingPreviousPage()

UseInfiniteQueryResult

In addition to all useQuery signals, useInfiniteQuery returns:

SignalTypeDescription
dataSignal<InfiniteData<TData> | undefined>Contains pages and pageParams arrays
isFetchingNextPageSignal<boolean>True while fetching the next page
isFetchingPreviousPageSignal<boolean>True while fetching the previous page
hasNextPageSignal<boolean>True if there are more pages to fetch
hasPreviousPageSignal<boolean>True if there are previous pages
MethodDescription
fetchNextPage()Fetch the next page. Returns a promise.
fetchPreviousPage()Fetch the previous page. Returns a promise.
refetch()Refetch all pages.

Cursor-Based Pagination

interface PostsResponse {
  posts: Post[]
  nextCursor: string | null
  previousCursor: string | null
}

const InfinitePostList = defineComponent(() => {
  const query = useInfiniteQuery(() => ({
    queryKey: ['posts'],
    queryFn: ({ pageParam }): Promise<PostsResponse> =>
      fetch(`/api/posts?cursor=${pageParam}&limit=20`).then((r) => r.json()),
    initialPageParam: '',
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    getPreviousPageParam: (firstPage) => firstPage.previousCursor,
  }))

  return () => (
    <div class="post-feed">
      {() => {
        if (query.isPending()) return <Spinner />
        if (query.isError()) return <p>Error: {String(query.error())}</p>

        const pages = query.data()?.pages ?? []
        return (
          <div>
            {pages.flatMap((page) =>
              page.posts.map((post) => <PostCard key={post.id} post={post} />),
            )}
            {query.hasNextPage() && (
              <button
                onClick={() => query.fetchNextPage()}
                disabled={() => query.isFetchingNextPage()}
              >
                {() => (query.isFetchingNextPage() ? 'Loading...' : 'Load More')}
              </button>
            )}
          </div>
        )
      }}
    </div>
  )
})

Infinite Scroll with Intersection Observer

const InfiniteScroll = defineComponent(() => {
  const query = useInfiniteQuery(() => ({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetch(`/api/feed?page=${pageParam}`).then((r) => r.json()),
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages) => (lastPage.hasMore ? allPages.length + 1 : undefined),
  }))

  return () => (
    <div class="feed">
      {() =>
        query
          .data()
          ?.pages.flatMap((page) =>
            page.items.map((item: FeedItem) => <FeedCard key={item.id} item={item} />),
          )
      }

      {/* Sentinel element -- triggers fetchNextPage when scrolled into view */}
      {() =>
        query.hasNextPage() && (
          <div
            ref={(el: HTMLDivElement) => {
              const observer = new IntersectionObserver(([entry]) => {
                if (entry.isIntersecting && !query.isFetchingNextPage()) {
                  query.fetchNextPage()
                }
              })
              observer.observe(el)
              onCleanup(() => observer.disconnect())
            }}
            class="loading-sentinel"
          >
            {() => query.isFetchingNextPage() && <Spinner />}
          </div>
        )
      }
    </div>
  )
})

Offset-Based Pagination

const PaginatedTable = defineComponent(() => {
  const page = signal(1)
  const pageSize = 25

  const query = useInfiniteQuery(() => ({
    queryKey: ['users', page()],
    queryFn: ({ pageParam }) =>
      fetch(`/api/users?offset=${(pageParam - 1) * pageSize}&limit=${pageSize}`).then((r) =>
        r.json(),
      ),
    initialPageParam: 1,
    getNextPageParam: (lastPage, _allPages, lastPageParam) =>
      lastPage.total > lastPageParam * pageSize ? lastPageParam + 1 : undefined,
  }))

  return () => (
    <div>
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
          </tr>
        </thead>
        <tbody>
          {() =>
            query.data()?.pages.flatMap((page) =>
              page.users.map((user: User) => (
                <tr key={user.id}>
                  <td>{user.name}</td>
                  <td>{user.email}</td>
                </tr>
              )),
            )
          }
        </tbody>
      </table>
      <div class="pagination">
        <button disabled={!query.hasPreviousPage()} onClick={() => query.fetchPreviousPage()}>
          Previous
        </button>
        <button disabled={!query.hasNextPage()} onClick={() => query.fetchNextPage()}>
          Next
        </button>
      </div>
    </div>
  )
})

Parallel Queries

useQueries

Subscribe to multiple queries in parallel. Returns a single signal containing the array of results, index-aligned with the input array.

import { useQueries } from '@pyreon/query'
import { signal } from '@pyreon/reactivity'

const userIds = signal([1, 2, 3])

const results = useQueries(() =>
  userIds().map((id) => ({
    queryKey: ['user', id],
    queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
  })),
)

// results() is QueryObserverResult[]
results()[0]?.data // first user
results()[1]?.data // second user

Dynamic Parallel Queries

When the query array changes (e.g., new IDs are added), the observer automatically subscribes to new queries and unsubscribes from removed ones:

const UserCards = defineComponent(() => {
  const selectedIds = signal<number[]>([1, 2])

  const results = useQueries(() =>
    selectedIds().map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
      staleTime: 5 * 60 * 1000,
    })),
  )

  return () => (
    <div class="user-cards">
      {() =>
        results().map((result, i) => {
          if (result.isPending) return <CardSkeleton key={selectedIds()[i]} />
          if (result.isError) return <CardError key={selectedIds()[i]} error={result.error} />
          return <UserCard key={selectedIds()[i]} user={result.data} />
        })
      }
    </div>
  )
})

Aggregating Parallel Results

const Dashboard = defineComponent(() => {
  const results = useQueries(() => [
    { queryKey: ['stats', 'revenue'], queryFn: fetchRevenue },
    { queryKey: ['stats', 'users'], queryFn: fetchUserCount },
    { queryKey: ['stats', 'orders'], queryFn: fetchOrderCount },
  ])

  return () => {
    const allResults = results()
    const isAnyLoading = allResults.some((r) => r.isPending)
    const isAnyError = allResults.some((r) => r.isError)

    if (isAnyLoading) return <DashboardSkeleton />
    if (isAnyError) return <DashboardError />

    return (
      <div class="dashboard-grid">
        <StatCard title="Revenue" value={allResults[0]?.data} />
        <StatCard title="Users" value={allResults[1]?.data} />
        <StatCard title="Orders" value={allResults[2]?.data} />
      </div>
    )
  }
})

Suspense Queries

useSuspenseQuery

Like useQuery, but data is typed as Signal<TData> (never undefined). Use inside a QuerySuspense boundary, which guarantees children only render after the query succeeds.

import { useSuspenseQuery, QuerySuspense } from '@pyreon/query'

const userQuery = useSuspenseQuery(() => ({
  queryKey: ['user', 1],
  queryFn: () => fetch('/api/users/1').then((r) => r.json()),
}))

// userQuery.data() is Signal<User>, not Signal<User | undefined>

UseSuspenseQueryResult

Same as UseQueryResult except:

SignalTypeDescription
dataSignal<TData>Always defined inside a QuerySuspense boundary

useSuspenseInfiniteQuery

Like useInfiniteQuery but with non-undefined data typing. Use inside a QuerySuspense boundary.

const postsQuery = useSuspenseInfiniteQuery(() => ({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts(pageParam),
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
}))

// postsQuery.data() is Signal<InfiniteData<PostsPage>>, never undefined

QuerySuspense

A Pyreon-native suspense boundary for queries. Shows a fallback while any query is pending, and optionally renders an error fallback.

import { QuerySuspense, useSuspenseQuery } from '@pyreon/query'

const UserCard = defineComponent(() => {
  const user = useSuspenseQuery(() => ({
    queryKey: ['user', 1],
    queryFn: fetchUser,
  }))

  return () => (
    <QuerySuspense
      query={user}
      fallback={<Spinner />}
      error={(err) => <p>Failed: {String(err)}</p>}
    >
      {() => <h1>{user.data().name}</h1>}
    </QuerySuspense>
  )
})

Multiple Queries

You can gate on multiple queries -- children render only when all succeed:

const Dashboard = defineComponent(() => {
  const userQuery = useSuspenseQuery(() => ({
    queryKey: ['user'],
    queryFn: fetchUser,
  }))
  const postsQuery = useSuspenseQuery(() => ({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  }))

  return () => (
    <QuerySuspense
      query={[userQuery, postsQuery]}
      fallback={<DashboardSkeleton />}
      error={(err) => <DashboardError error={err} />}
    >
      {() => (
        <div class="dashboard">
          <UserHeader user={userQuery.data()} />
          <PostList posts={postsQuery.data()} />
        </div>
      )}
    </QuerySuspense>
  )
})

Nested Suspense Boundaries

function App() {
  return () => (
    <div class="layout">
      {/* Header loads independently */}
      <QuerySuspense query={headerQuery} fallback={<HeaderSkeleton />}>
        {() => <Header data={headerQuery.data()} />}
      </QuerySuspense>

      <div class="content">
        {/* Sidebar loads independently */}
        <QuerySuspense query={sidebarQuery} fallback={<SidebarSkeleton />}>
          {() => <Sidebar items={sidebarQuery.data()} />}
        </QuerySuspense>

        {/* Main content loads independently */}
        <QuerySuspense query={contentQuery} fallback={<ContentSkeleton />}>
          {() => <MainContent data={contentQuery.data()} />}
        </QuerySuspense>
      </div>
    </div>
  )
}

QuerySuspenseProps

PropTypeDescription
queryAnyQueryLike | AnyQueryLike[]Query result(s) to gate on
fallbackVNodeChildRendered while any query is pending
error(err: unknown) => VNodeChildRendered on error (defaults to re-throwing to nearest ErrorBoundary)
childrenVNodeChildRendered when all queries have succeeded

Dependent / Serial Queries

Use the enabled option to create query chains where one query depends on the result of another:

const UserProfile = defineComponent((props: { userId: number }) => {
  // First query: fetch the user
  const userQuery = useQuery(() => ({
    queryKey: ['user', props.userId],
    queryFn: () => fetch(`/api/users/${props.userId}`).then((r) => r.json()),
  }))

  // Second query: fetch the user's team -- depends on user data
  const teamQuery = useQuery(() => ({
    queryKey: ['team', userQuery.data()?.teamId],
    queryFn: () => fetch(`/api/teams/${userQuery.data()!.teamId}`).then((r) => r.json()),
    // Only run when we have the teamId from the first query
    enabled: userQuery.data()?.teamId != null,
  }))

  // Third query: fetch team members -- depends on team data
  const membersQuery = useQuery(() => ({
    queryKey: ['members', teamQuery.data()?.id],
    queryFn: () => fetch(`/api/teams/${teamQuery.data()!.id}/members`).then((r) => r.json()),
    enabled: teamQuery.data()?.id != null,
  }))

  return () => (
    <div>
      {() => {
        if (userQuery.isPending()) return <p>Loading user...</p>
        if (userQuery.isError()) return <p>Error loading user</p>

        return (
          <div>
            <h1>{userQuery.data()?.name}</h1>
            {() => {
              if (teamQuery.isPending()) return <p>Loading team...</p>
              return <p>Team: {teamQuery.data()?.name}</p>
            }}
            {() => {
              if (membersQuery.isPending()) return <p>Loading members...</p>
              return (
                <ul>
                  {membersQuery.data()?.map((m: User) => (
                    <li key={m.id}>{m.name}</li>
                  ))}
                </ul>
              )
            }}
          </div>
        )
      }}
    </div>
  )
})

Authenticated Requests

Send a token from the queryFn, and put the signed-in user in the queryKey:

// @check
import { useQuery } from '@pyreon/query'
import { signal } from '@pyreon/reactivity'

type Me = { name: string; email: string }

// Wherever your session lives — a store, a context, `useOidc`'s accessToken() / subject().
const session = signal<{ userId: string; token: string } | null>(null)

export function useMe() {
  return useQuery<Me>(() => ({
    queryKey: ['me', session()?.userId ?? 'anonymous'],
    queryFn: () =>
      fetch('https://api.example.com/me', {
        headers: { authorization: `Bearer ${session()?.token ?? ''}` },
      }).then((r) => r.json()),
    enabled: session() !== null,
  }))
}

Two details make this correct:

  • The header is built inside queryFn, which runs on every fetch, so each request carries the token that is current at that moment, including after a refresh.

  • The user is in the key. Signing in, or switching accounts, changes the key, so the query refetches with the new token. It never serves the previous user's cached /me.

The same source works unchanged on iOS and Android. The compiler lowers the header value to native code that is evaluated each time the request is built, and the key change re-runs it there exactly as on the web.

A header value has to be a string. One that may be null (authorization: session()?.token) is refused on native with a warning naming the fix — the web would send the literal text null — so give it a fallback as above. Avoid putting a token in useFetch's init.headers: useFetch builds its request once, at setup, so the header would keep the token from that moment (on every target).

Query Invalidation and Refetching

Use the QueryClient to invalidate queries, triggering background refetches:

const client = useQueryClient()

// Invalidate a specific query
client.invalidateQueries({ queryKey: ['todos'] })

// Invalidate all queries that start with 'user'
client.invalidateQueries({ queryKey: ['user'] })

// Invalidate everything
client.invalidateQueries()

// Invalidate with a predicate
client.invalidateQueries({
  predicate: (query) => query.queryKey[0] === 'todos' && query.state.data?.length > 10,
})

// Refetch (force immediate refetch, not just mark stale)
client.refetchQueries({ queryKey: ['todos'] })

// Remove queries from cache entirely
client.removeQueries({ queryKey: ['old-data'] })

// Manually set query data (e.g., after a mutation)
client.setQueryData(['todo', 1], {
  id: 1,
  title: 'Updated title',
  completed: true,
})

// Prefetch (fetch into cache without subscribing)
client.prefetchQuery({
  queryKey: ['user', 5],
  queryFn: () => fetchUser(5),
})

Prefetching on Hover

const UserLink = defineComponent((props: { userId: number; name: string }) => {
  const client = useQueryClient()

  const prefetch = () => {
    client.prefetchQuery({
      queryKey: ['user', props.userId],
      queryFn: () => fetchUser(props.userId),
      staleTime: 5 * 60 * 1000,
    })
  }

  return () => (
    <a href={`/users/${props.userId}`} onMouseEnter={prefetch} onFocus={prefetch}>
      {props.name}
    </a>
  )
})

Global Loading Indicators

useIsFetching

Returns a signal tracking how many queries are currently in-flight. Useful for global loading indicators.

import { useIsFetching } from '@pyreon/query'

const fetching = useIsFetching()
// In template: () => fetching() > 0 ? 'Loading...' : ''

With filters:

// Only track queries that match the filter
const fetchingTodos = useIsFetching({ queryKey: ['todos'] })

useIsMutating

Returns a signal tracking how many mutations are currently in-flight.

import { useIsMutating } from '@pyreon/query'

const mutating = useIsMutating()
// In template: () => mutating() > 0 ? 'Saving...' : ''

Global Loading Bar

const GlobalLoadingBar = defineComponent(() => {
  const fetching = useIsFetching()
  const mutating = useIsMutating()

  return () => (
    <Show when={() => fetching() > 0 || mutating() > 0}>
      <div class="global-loading-bar">
        <div class="progress-bar" />
      </div>
    </Show>
  )
})

Error Reset Boundary

QueryErrorResetBoundary

Wraps a subtree so that useQueryErrorResetBoundary() descendants can reset all errored queries within this boundary. Pair with Pyreon's ErrorBoundary for retry patterns.

import { QueryErrorResetBoundary, useQueryErrorResetBoundary } from '@pyreon/query'

const App = defineComponent(() => {
  return () => (
    <QueryErrorResetBoundary>
      <ErrorBoundary
        fallback={(err, boundaryReset) => {
          const { reset } = useQueryErrorResetBoundary()
          return (
            <div>
              <p>Something went wrong: {String(err)}</p>
              <button
                onClick={() => {
                  reset()
                  boundaryReset()
                }}
              >
                Retry
              </button>
            </div>
          )
        }}
      >
        <MyComponent />
      </ErrorBoundary>
    </QueryErrorResetBoundary>
  )
})

useQueryErrorResetBoundary

Returns &#123; reset &#125; -- call reset() to refetch all errored queries in the nearest boundary. If called outside a boundary, falls back to resetting all errored queries on the QueryClient.

const { reset } = useQueryErrorResetBoundary()
// reset() refetches all queries where state.status === "error"

Error Handling and Retry Strategies

Per-Query Retry

const query = useQuery(() => ({
  queryKey: ['critical-data'],
  queryFn: fetchCriticalData,
  retry: 5, // Retry 5 times
  retryDelay: (
    attempt, // Exponential backoff with jitter
  ) => Math.min(1000 * 2 ** attempt, 30000) + Math.random() * 1000,
}))

Conditional Retry

const query = useQuery(() => ({
  queryKey: ['api-data'],
  queryFn: fetchData,
  retry: (failureCount, error) => {
    // Don't retry on 401/403 (auth errors)
    if ((error as Response)?.status === 401) return false
    if ((error as Response)?.status === 403) return false
    // Don't retry on 404
    if ((error as Response)?.status === 404) return false
    // Retry up to 3 times for other errors
    return failureCount < 3
  },
}))

Error Handling Patterns

const DataComponent = defineComponent(() => {
  const query = useQuery(() => ({
    queryKey: ['data'],
    queryFn: fetchData,
  }))

  return () => {
    // Pattern 1: Inline error handling
    if (query.isError()) {
      const error = query.error()
      if (error instanceof NotFoundError) {
        return <NotFoundPage />
      }
      return (
        <div class="error">
          <p>Failed to load data: {String(error)}</p>
          <button onClick={() => query.refetch()}>Retry</button>
        </div>
      )
    }

    if (query.isPending()) return <Spinner />
    return <DataView data={query.data()} />
  }
})

SSR with Dehydration

Prefetch queries on the server, serialize them, and hydrate on the client:

// --- server.ts ---
import { QueryClient, dehydrate } from '@pyreon/query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // Data is fresh for 1 minute
    },
  },
})

// Prefetch all the data your page needs
await Promise.all([
  queryClient.prefetchQuery({
    queryKey: ['user', 1],
    queryFn: fetchUser,
  }),
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  }),
])

const dehydratedState = dehydrate(queryClient)

// Embed in HTML
const html = `
  <script>
    window.__PYREON_QUERY_STATE__ = ${JSON.stringify(dehydratedState)}
  </script>
`
// --- client.ts ---
import { QueryClient, hydrate } from '@pyreon/query'

const queryClient = new QueryClient()

// Hydrate the cache with server-prefetched data
const dehydratedState = (window as any).__PYREON_QUERY_STATE__
if (dehydratedState) {
  hydrate(queryClient, dehydratedState)
}

// Now mount the app -- queries will use cached data immediately
mount(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
  document.getElementById('app')!
)

Custom Dehydration Predicates

const dehydratedState = dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // Only dehydrate successful queries
    return query.state.status === 'success'
  },
  shouldDehydrateMutation: defaultShouldDehydrateMutation,
})

TanStack Query Core Re-exports

The following are re-exported from @tanstack/query-core for convenience:

ExportDescription
QueryClientThe query client class
QueryCacheLow-level query cache
MutationCacheLow-level mutation cache
dehydrateSerialize query cache for SSR
hydrateRestore query cache from serialized state
defaultShouldDehydrateQueryDefault dehydration predicate
defaultShouldDehydrateMutationDefault mutation dehydration predicate
keepPreviousDataPlaceholder data strategy
hashKeyHash a query key
isCancelledErrorCheck if an error is a cancellation
CancelledErrorCancellation error class
skipTokenType-safe way to disable a query (queryFn: skipToken)
focusManager / onlineManager / notifyManagerquery-core's focus, online and batching managers
matchQuery / matchMutation / replaceEqualDeepFilter-matching and structural-sharing utilities
isServerquery-core's server-environment flag
QueryObserver / InfiniteQueryObserver / MutationObserver / QueriesObserverObserver classes, for driving query-core directly

Type re-exports include QueryKey, QueryFilters, MutationFilters, DehydratedState, FetchQueryOptions, InvalidateQueryFilters, InvalidateOptions, RefetchQueryFilters, RefetchOptions, and QueryClientConfig.

Pyreon Adapter Exports

ExportDescription
QueryClientProviderContext provider component
QueryClientContextThe raw context object
useQueryClientAccess the nearest QueryClient
useQuerySubscribe to a query
useMutationRun a mutation
useInfiniteQuerySubscribe to a paginated query
useQueriesSubscribe to multiple queries in parallel
useSuspenseQueryQuery with non-undefined data typing
useSuspenseInfiniteQueryInfinite query with non-undefined data typing
QuerySuspensePyreon-native suspense boundary
useIsFetchingSignal tracking in-flight query count
useIsMutatingSignal tracking in-flight mutation count
QueryErrorResetBoundaryError reset boundary component
useQueryErrorResetBoundaryAccess error reset function
useSuspenseQueriesuseQueries shaped for a QuerySuspense boundary
defineQueriesNamed parallel queries: defineQueries({ user: () => ({…}), posts: () => ({…}) }) returns { user, posts } results
useMutationStateSignal of mutation states matching a filter (useMutationState(() => ({ filters, select })))
usePrefetchQuery / usePrefetchInfiniteQueryPrefetch at setup if the key is not cached yet (options function, read once)
HydrationBoundaryHydrate a dehydrated state into the client before rendering children
IsRestoringProvider / useIsRestoringReactive "the persisted cache is being restored" flag
useSubscriptionWebSocket → query cache (see below)
useSSEServer-Sent Events → query cache
useStreamAsync-iterable stream (SSE / NDJSON) as reactive state (see below)

Subpaths

  • @pyreon/query/devtools — <QueryDevtools client? initialIsOpen? buttonPosition? position? errorTypes? shadowDOMTarget?>, the TanStack Query devtools panel.

  • @pyreon/query/persist — <PersistQueryClientProvider client persistOptions onSuccess? onError?> (a QueryClientProvider that restores the cache on mount and keeps it persisted), createSyncStoragePersister / createAsyncStoragePersister, and the query-persist-client-core functions (persistQueryClient, persistQueryClientRestore, persistQueryClientSave, persistQueryClientSubscribe, experimental_createQueryPersister, removeOldestQuery).

Multiplatform

The full client wraps TanStack Query, a JavaScript runtime cache, so QueryClient configuration, devtools, and infinite / suspense queries are web only. On iOS and Android (PMTC), useQuery (key + fetcher, loading / error / data state) lowers to the native PyreonQuery runtime, useMutation over a same-file @pyreon/http endpoint (useMutation<T>(ep.mutation()), or mutationFn: (vars) => ep({ json: vars })) lowers to PyreonMutation — each mutate / mutateAsync builds its request at the call site from runtime values, see Sending what the user entered — and useStream over openEventStream / openNdjsonStream lowers to PyreonStream.

WebSocket Subscriptions — useSubscription()

Connect a WebSocket to the query cache for realtime data updates. Auto-reconnects with exponential backoff.

import { useSubscription } from '@pyreon/query'

function OrdersDashboard() {
  const sub = useSubscription({
    url: 'wss://api.example.com/ws',
    onMessage: (event, queryClient) => {
      const data = JSON.parse(event.data)
      if (data.type === 'order-updated') {
        queryClient.invalidateQueries({ queryKey: ['orders'] })
      }
      if (data.type === 'order-created') {
        queryClient.setQueryData(['orders', data.order.id], data.order)
      }
    },
  })

  return (
    <div>
      <p>Status: {() => sub.status()}</p>
      {/* sub.status(): 'connecting' | 'connected' | 'disconnected' | 'error' */}
    </div>
  )
}

Subscription Options

OptionTypeDefaultDescription
urlstring | () => stringrequiredWebSocket URL (can be reactive)
protocolsstring | string[]—WebSocket sub-protocols
onMessage(event, queryClient) => voidrequiredMessage handler with query client access
onOpen(event) => void—Connection opened callback
onClose(event) => void—Connection closed callback
onError(event) => void—Error callback
reconnectbooleantrueAuto-reconnect on disconnect
reconnectDelaynumber1000Initial reconnect delay (ms), doubles each retry
maxReconnectAttemptsnumber10Max reconnect attempts (0 = unlimited)
enabledboolean | () => booleantrueEnable/disable the connection

Return Value

PropertyTypeDescription
statusSignal<SubscriptionStatus>Current connection status
send(data) => voidSend data through the WebSocket
close() => voidClose the connection
reconnect() => voidManually reconnect

Reactive URL

const channel = signal('orders')

useSubscription({
  url: () => `wss://api.example.com/ws/${channel()}`,
  onMessage: (event, qc) => {
    /* ... */
  },
})

// Changing channel automatically reconnects to the new URL
channel.set('inventory')

Conditional Connection

const isAuthenticated = computed(() => !!token())

useSubscription({
  url: 'wss://api.example.com/ws',
  enabled: () => isAuthenticated(),
  onMessage: (event, qc) => {
    /* ... */
  },
})

Streams — useStream()

useSSE wraps the browser's EventSource, which is GET-only and cannot send headers. useStream consumes any async iterable instead — typicallyopenEventStream / openNdjsonStream from @pyreon/http/stream — so the stream can be a POST with auth, validated per event, and mocked in tests.

import { openEventStream } from '@pyreon/http/stream'
import { useStream } from '@pyreon/query'

function Feed(props: { room: () => string }) {
  const feed = useStream(
    (ctx) =>
      openEventStream((c) => roomEvents({ params: { room: props.room() }, signal: c.signal, headers: c.headers }), {
        signal: ctx.signal,
        onStatus: ctx.onStatus,
      }),
    { maxEvents: 200 },
  )
  return (
    <p>
      {() => feed.status()}: {() => feed.latest()?.data.text ?? '…'} ({() => feed.events().length} so far)
    </p>
  )
}
Returns
events()events since the stream (re)started, oldest first — at most maxEvents (default 1000)
latest()the most recent event
status()'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed' | 'error'
error()what ended the stream, or undefined
abort() / restart()stop it (an input change does not revive it) / open a fresh one
  • The source runs in a tracking scope: a signal it reads (props.room()above) re-opens the stream when it changes — the previous request is aborted and its late events are dropped.

  • Return undefined from the source while the inputs are not ready; the hook stays idle.

  • Unmount aborts the request.

  • events() is bounded (maxEvents) because a stream can run for hours; pass Infinity only for a stream you know ends.

  • onEvent(event, queryClient) runs per event — write into the query cache from a live feed.

A Lathe-generated client emits a typed use<Op>Stream for every streaming operation, built on this hook.

Type Exports

TypeDescription
UseQueryResultReturn type of useQuery
UseMutationResultReturn type of useMutation
UseInfiniteQueryResultReturn type of useInfiniteQuery
UseQueriesOptionsOptions type for useQueries
UseSuspenseQueryResultReturn type of useSuspenseQuery
UseSuspenseInfiniteQueryResultReturn type of useSuspenseInfiniteQuery
QuerySuspensePropsProps for QuerySuspense
QueryClientProviderPropsProps for QueryClientProvider
QueryErrorResetBoundaryPropsProps for QueryErrorResetBoundary
UseSubscriptionOptionsOptions for useSubscription
UseSubscriptionResultReturn type of useSubscription
SubscriptionStatus'connecting' | 'connected' | 'disconnected' | 'error'
UseSSEOptions / UseSSEResult / SSEStatusOptions, result and status of useSSE (SSEStatus adds 'failed')
UseStreamOptions / UseStreamResult / StreamSource / StreamSourceContext / StreamStateTypes for useStream
UseMutationStateOptionsOptions for useMutationState
UseQueryOptions / MutationOptionsOptions for useQuery / useMutation
UseSuspenseQueriesResultReturn type of useSuspenseQueries
QueryData / QueryErrorExtract the data / error type from a query result
HydrationBoundaryProps / IsRestoringProviderPropsProps for those components
Query