DocsBlog
  • 1.3.0

  • light

    dark

    system

    Switch mode
  • Cerberus

    Acheron

    Elysium

Get Started
Components
Data Grid
Signals
Styling
Theming

Get Started

OverviewReactivityData FetchingStores

Primitives

createSignalcreateQueryNewcreateMutationNewcreateComputedcreateEffectcreateStoreContextbatchonCleanupNewuntrackNew

Hooks

useQueryNewuseMutationuseReaduseSignaluseStoreNew

Components

ReactiveText

On this page

Loading...

Loading...

Loading...

Loading...

Creating Queries

Learn how to create cached queries in React.

  • source
import { createQuery, useQuery } from '@cerberus/signals'

Usage

createQuery allows you define a fetcher factory that is auto-fetched and cached with or without an Accessor value. This creates a streamlined and high-performant way to fetch data within your application with less effort.

The value of a query is inteded to be consumed by useQuery.

A query is just a fancy wrapper that subscribes to the signals Observer. This means that it can be used for any scenario that is Promise-based - not just data-fetching. If you have a Promise, it can be a query.

See the features table to understand how to best utilize queries for each scenario you may have.

Note

Queries are compatible with React Suspense and Error Boundaries.

Creating Queries

Creating a query requires two things:

  1. A Promise to call when activated
  2. A unique key which is used to cache the result

Ultimately, when you create a query you are really just creating a factory that can be called where ever and whenever you are ready.

Using Queries

Depending on your goals will determine how to use the query. The following sections outline the different use cases depending on whether you are fetching data within a component or outside of one.

There are no restrictions to Cerberus Signal Primitives which means you can use them at any scope.

With Components

createQuery only defines a query factory and does not actually call it. To use the query in a component you must pass the returned value into the useQuery hook which re-runs when the arguments provided change.

const data = useQuery(myQuery(props.id))

The value returned is the result from calling the signal-tracked Accessor. This is equivalent to the return value from the useRead hook.

Without Components

There are two different patterns you can use depending on your fetching goals outside of components.

Pattern 1: Imperative Await (Standard Async)

If you just want to trigger the fetch and wait for the result in a standard async function, you can simply await the Promise attached to the pending state.

Pattern 2: Reactive Subscription (The Signal Way)

If you want a vanilla JS function to automatically react to the data whenever it arrives (or whenever it is invalidated and refetched in the background), you drop it into a createEffect.

This is incredibly useful for syncing data to vanilla DOM elements, Web Components, or external non-React stores.

Streaming Reponses (Async Generators)

Cerberus queries also support streaming data via Async Generators. This is powerful if you are using an LLM API or your local API supports data streaming.

Server Component Pattern

In an SSR environment, you execute the factory's raw fetcher directly, bypassing the reactive cache. Then, you pass that data down to your Client Components to "hydrate" or seed the Cerberus cache, ensuring the client doesn't double-fetch on mount.

This is the standard SSR pattern for React.

1. Server Component (Fetching)

Expose the raw, stateless fetcher function from your factory. You simply await it like a standard asynchronous function.

2. Client Component

On the client side, use the initialData property. If the Cerberus cache is empty, it will instantly seed the cache with the server's data, skipping the <Suspense> boundary entirely.

Optimistic Updates

Queries have a built-in optimistic store when combined with Mutations. This means the UI will update in "real-time" while the query will fetch in the background and eventually overwrite the optimistic store.

Mutations are only necessary if you are writing updates. If your query is only intended to be read-only, it will automagically fetch when the params change.

Features Table

FeatureWith SignalWithout Signal
auto-intial fetch✅✅
auto-refresh fetch✅✅
caching✅✅
requires mutation❌❌

API

createQuery accepts the following options:

ParamsRequiredDescription
Promise<any>trueThe Promise to call when activated.
keytrueA unique key used for caching queries.

Return

createQuery returns the value of the Promise along with some additional properties:

PropertyTypeDescription
keyUUIDA uniqe ID to be referenced in the mutation invalidate Array.
currentArgsArgsThe args passed into createQuery stored in a reference.

Note

The query.key is SHA generated from the contents of the query parameters. This means, if you have multiple signal-like queries, they should each have unique return values or else they will be synced together.

Typing

createQuery accepts two arguements to strictly type the return result <CacheNode, Args>. These are naturally deferred through our strict type chain. This means you don't need to worry about it outside of rare use cases.

However, we do recommend using this if you are creating a query that doesn't expect any arguments: createQuery<string, void>(...).

Copy
'use client'

import { Box, Stack } from '@/styled-system/jsx'
import { Button } from '@cerberus/react'
import { createQuery, useQuery, useSignal } from '@cerberus/signals'
import { Suspense } from 'react'

// Define Query Factory
const query = createQuery(fetchUser, 'get-user')

interface UserInfoProps {
  user: User['id']
}

function UserInfo(props: UserInfoProps) {
  const data = useQuery(query(props.user))
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

export function BasicDemo() {
  // pretend this is from route params instead of a signal. useSignal will force
  // the render update like a URL param change would.
  const [user, setUser] = useSignal<User['id']>(crypto.randomUUID())

  return (
    <Stack direction="column" justify="space-between" w="3/4">
      <Suspense fallback={<Box aria-busy h="96px" rounded="sm" w="full" />}>
        <UserInfo user={user} />
      </Suspense>

      <Button onClick={() => setUser(crypto.randomUUID())}>Change User</Button>
    </Stack>
  )
}

// API

type User = {
  id: string
  name: string
}

function fetchUser(id: User['id']): Promise<User> {
  return new Promise<User>((resolve) => {
    setTimeout(() => {
      resolve({ id, name: `User ${id}` })
    }, 500)
  })
}
Copy
// app/users/[id]/page.tsx (Server Component)

import { UserProfile } from './client.demo'
import { queryUser } from './queries'

interface Props {
  params: Promise<{ id: string }>
}

export default async function UserPage({ params }: Props) {
  const { id } = await params
  // Bypass the reactive cache and execute the raw fetcher directly.
  // This is completely memory-safe for Node.js environments.
  const initialUserData = await queryUser.fetcher(id)

  if (!initialUserData) return null

  return (
    <main>
      {/* 2. Pass the fetched data to the Client Component */}
      <UserProfile id={id} initialData={initialUserData} />
    </main>
  )
}
'use client'

import { useQuery } from '@cerberus/signals'
import { queryUser } from './queries'
import { type User } from './db'

interface Props {
  id: string
  initialData: User
}

export function UserProfile(props: Props) {
  // 1. Cerberus sees `initialData`, instantly seeds the $O(1) Map,
  // and mounts the component with zero loading spinners or network waterfalls.
  const user = useQuery(queryUser(props.id), { initialData: props.initialData })

  if (!user) return null

  return <h1>{user.name}</h1>
}
'use client'

import { Stack } from '@/styled-system/jsx'
import { Button, Field, Group, Input, Show, Text } from '@cerberus/react'
import { createQuery, useQuery, useSignal } from '@cerberus/signals'
import { ChangeEvent, Suspense } from 'react'

// --- 1. Fake Streaming LLM API ---
// Simulates an AI sending text chunks over a network
async function* fakeLLMStream(prompt: string) {
  const responseText = `You asked: "${prompt}".\n\nThis is a simulated streaming response from the Cerberus Signals engine. Notice how the text appears chunk by chunk. Because the query engine natively understands Async Generators, it bypasses React Suspense boundaries mid-stream, achieving an instant, seamless real-time UX without complex socket bindings.`

  const words = responseText.split(' ')

  for (let i = 0; i < words.length; i++) {
    // Simulate 50ms network delay per word chunk
    await new Promise((res) => setTimeout(res, 50))
    yield words[i] + ' '
  }
}

// --- 2. Define Streaming Query Factory ---
// The fetcher uses `async function*` and yields the accumulated state
const getChatStream = createQuery(async function* (prompt: string) {
  let fullText = ''

  // Listen to the stream and accumulate chunks
  for await (const chunk of fakeLLMStream(prompt)) {
    fullText += chunk
    // The moment this yields, Cerberus sets the cache status to 'streaming'
    // and passes the partial data directly to the UI
    yield fullText
  }
}, 'queryChatStream')

// --- UI ---

function ChatWindow(props: { prompt: string }) {
  const chatResponse = useQuery(getChatStream(props.prompt))
  return (
    <Stack gap="md" p="md" bg="surface.container" rounded="md">
      <Text textStyle="body-md">{chatResponse}</Text>
    </Stack>
  )
}

export function StreamingDemo() {
  // we use hooks to force re-renders of the form content
  const [input, setInput] = useSignal('How does Cerberus handle streams?')
  const [submittedPrompt, setSubmittedPrompt] = useSignal('')

  return (
    <Stack gap="lg" w="3/4">
      <Show when={submittedPrompt}>
        {() => (
          <Suspense
            fallback={
              <Text as="small" textStyle="body-xs">
                Thinking...
              </Text>
            }
          >
            <ChatWindow prompt={submittedPrompt} />
          </Suspense>
        )}
      </Show>

      <Group layout="attached" w="full">
        <Field required>
          <Input
            onChange={(e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value)}
            value={input}
            borderTopRightRadius="none"
            borderBottomRightRadius="none"
            h="2.75rem"
          />
        </Field>
        <Button onClick={() => setSubmittedPrompt(input)}>Send Prompt</Button>
      </Group>
    </Stack>
  )
}
import { createQuery, createEffect } from '@cerberus/signals'

const queryUUID = createQuery(fetchUUID, 'queryUUID')

export function main() {
  // 1. Get the reactive accessor for this specific query
  const getUUIDState = queryUUID({})

  // 2. This effect runs immediately, automatically tracking the accessor.
  // If the cache is empty, reading getUUIDState() triggers the background fetch!
  createEffect(() => {
    // Read the live signal state directly
    const state = getUUIDState()

    if (state.status === 'pending') {
      console.log('Loading UUID...')
    } else if (state.status === 'success') {
      console.log('Syncing UUID to external system:', state.data)
      // document.getElementById('my-span').innerText = state.data
    } else if (state.status === 'error') {
      console.error('Failed to load UUID:', state.error)
    }
  })
}

// API

type UUID = string
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

async function fetchUUID(): Promise<UUID> {
  await delay(500)
  return crypto.randomUUID()
}
import { createQuery } from '@cerberus/signals'

const getUUID = createQuery(fetchUUID, 'queryUUID')

export async function main() {
  const state = getUUID(undefined)()

  if (state.status === 'pending' && state.promise) {
    try {
      // Await the underlying fetch directly
      const uuid = await state.promise
      console.log('Fetched successfully:', uuid)
    } catch (error) {
      console.error('Fetch failed:', error)
    }
  } else if (state.status === 'success') {
    // If it was already cached, just use it instantly
    console.log('Read from cache:', state.data)
  }
}

// API

type UUID = string

function fetchUUID(): Promise<UUID> {
  return new Promise<UUID>((resolve) => {
    setTimeout(() => {
      resolve(crypto.randomUUID())
    }, 500)
  })
}

On this page

  • Usage
  • Creating Queries
  • Using Queries
  • With Components
  • Without Components
  • Streaming Reponses (Async Generators)
  • Server Component Pattern
  • 1. Server Component (Fetching)
  • 2. Client Component
  • Optimistic Updates
  • Features Table
  • API
  • Return
  • Typing
  • Edit this page on Github
{
  "id": "bf5cd346-0ebd-489f-ad0f-923dc02e27b1",
  "name": "User bf5cd346-0ebd-489f-ad0f-923dc02e27b1"
}