pyreon

Data fetching — useQuery + useMutation

The pattern

Pass query options as a function (not an object) so signal reads inside track automatically:

import { useQuery, useMutation, QueryClientProvider, QueryClient } from '@pyreon/query'
import { signal } from '@pyreon/reactivity'

const queryClient = new QueryClient()

function Root() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  )
}

function UserCard() {
  const userId = signal('alice')
  // Options function — signal reads track, so changing userId refetches.
  const user = useQuery(() => ({
    queryKey: ['user', userId()],
    queryFn: () => api.fetchUser(userId()),
    staleTime: 30_000,
  }))

  return (
    <div>
      {() => user.isLoading() && <Spinner />}
      {() => user.data() && <h1>{user.data()!.name}</h1>}
      <button onClick={() => userId.set('bob')}>Load Bob</button>
    </div>
  )
}

Mutations use an object (mutations are imperative — no tracking needed) + invalidates to clear matching queries on success:

function DeleteButton(props: { id: string }) {
  const deleteUser = useMutation({
    mutationFn: (id: string) => api.deleteUser(id),
    invalidates: [['users']],   // all queries with this prefix invalidate on success
  })

  return (
    <button
      disabled={() => deleteUser.isPending()}
      onClick={() => deleteUser.mutate(props.id)}
    >
      Delete
    </button>
  )
}

Key differences from TanStack Query

  • useQuery / useInfiniteQuery / useQueries / useSuspenseQuery take options as a function — changing a tracked signal re-runs the observer options and refetches.

  • useMutation options are a plain object — mutations fire imperatively via .mutate(...), so there's nothing to track.

  • Every query field is a fine-grained Signal<T> — reading user.data() does not subscribe to user.error() or user.isFetching().

  • useMutation({ invalidates: [...] }) auto-invalidates keys on success, preserving user onSuccess.

  • defineQueries({ user: () => opts, posts: () => opts }) — named parallel queries returning a typed object, cleaner than the array form.

Why

Pyreon components run once, so a plain options object would bake the initial values in forever. Wrapping in a function lets Pyreon's reactivity system re-invoke the options and drive the TanStack Query observer on each change — the TanStack primitive stays pure, the adapter handles tracking.

Anti-pattern

// BROKEN — options object evaluated ONCE at component setup,
// user.data() never updates even though userId changes.
const user = useQuery({
  queryKey: ['user', userId()],  // read at setup, captures 'alice' forever
  queryFn: () => api.fetchUser(userId()),
})
// BROKEN — mutate() fires immediately on render (the call happens
// during prop evaluation; the handler prop gets the return value,
// not a function)
const Broken = () => <button onClick={deleteUser.mutate(id)}>Delete</button>

// Correct — wrap in an arrow so onClick receives a callable
const Correct = () => <button onClick={() => deleteUser.mutate(id)}>Delete</button>
  • Reference API: useQuery, useMutation, useSubscription, useSSEget_api({ package: "query", symbol: "..." })

  • Related: WebSocket subscription via useSubscription({ url, enabled }) uses the same options-as-function pattern

Data fetching — useQuery + useMutation