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...

Signals Data Fetching

Learn the fundamentals of fetching data using Signals.

  • source

Introduction

Fetching data from a remote API or database is a core task for most applications. Cerberus Signals provide foundational primitives like createQuery and createMutation to manage asynchronous data.

Cerberus Signals data fetching APIs come with the following benefits:

  1. High performing: our benchmarks outperform Tanstack queries by over 60% in some areas
  2. Caching: all queries are cached until invalidated
  3. Optimistic updates: built in support for "real-time" optimistic UI updates
  4. Data Streaming: compatible with Async Generators for LLM responses
  5. SSR Sync: fetch data on the server and sync it to a client-side query
  6. Suspense: native support for React Suspense
  7. Error Boundaries: native support for Error Boundaries

Fetching Data

To fetch (and cache) data using signals, simply follow two steps:

  1. Define a query factory via createQuery
  2. Use the factory via useQuery in your component

In this example we use a Signal to trigger a new query request. When using this design you, opt-out of optimistic UI updates and fallback to legacy loading-based UI changes.


In this example there are a few things happening:

  1. The "backend API"
  2. The query factory
  3. The component using the query
  4. An action that updates the global currentUser state

When you pass an Signal Accessor into the query definition, it will auto-fetch, invalidate, and cache the result when the signal Accessor updates. This means, with this design mutations are not neccessary - but still strongly recommended.

Optimistically Updating Query Data

When you want to perform an action related to a query, you utilize a mutation factory via createMutation.

When combined with query.key, this factory will automagically sync and update the query if it is listed in the invalidate options.

Even more, when combined with onMutate/onSetData, the UI will optimistically update while the query runs in the background creating a "real-time" like experience in the UI.


Here's what's happening in this demo:

  1. A query factory is created
  2. A mutation factory is created a. onMutate/setQueryData provides optimistic updates to the query b. invalidate breaks the query cache to ensure the latest data is fetched in the background
  3. The UI is automagically synced with the query data
  4. Actions call the mutate helper to trigger mutations.

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.

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.

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
idle
Copy
'use client'

import { Box, HStack, Stack } from '@/styled-system/jsx'
import { Button, Tag, Text } from '@cerberus/react'
import {
  createMutation,
  createQuery,
  setQueryData,
  useMutation,
  useQuery,
} from '@cerberus/signals'
import { Suspense } from 'react'

// 1. Define Query Factory
const getUser = createQuery(async (id: string) => {
  return await api.getUser(id)
}, 'queryGetUser')

// 2. Define Mutation Factory
const updateUser = createMutation((payload: User) => api.updateUser(payload), {
  // Optimistically update the UI instantly
  onMutate: (vars) => {
    setQueryData<User>(getUser.key(vars.id), (prev) => {
      if (!prev) return { id: vars.id, name: vars.name }
      return { ...prev, name: vars.name }
    })
  },
  // Declarative cache invalidation using factory keys
  invalidate: (_data, vars) => [getUser.key(vars.id)],
})

// 3. Consume in Components seamlessly
function UserProfile(props: { id: User['id'] }) {
  const user = useQuery(getUser(props.id))
  return <Text>{user.name}</Text>
}

export function MutationDemo() {
  // Pretend this is from the URL or via props.id
  const id = crypto.randomUUID()

  const { mutate, status } = useMutation(updateUser)

  function handleUpdate() {
    mutate({ id, name: `User ${crypto.randomUUID()}` })
  }

  return (
    <HStack gap="lg" w="3/4">
      <Button onClick={handleUpdate}>Update Name</Button>

      <Stack>
        <Suspense fallback={<Box aria-busy h="44px" rounded="sm" w="366px" />}>
          <UserProfile id={id} />
        </Suspense>
        <Tag w="fit-content">{status}</Tag>
      </Stack>
    </HStack>
  )
}

// API

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

const fakeDB = new Map<string, User>()
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))

const api = {
  getUser: async (id: User['id']) => {
    await delay(500)
    if (!fakeDB.has(id)) {
      fakeDB.set(id, { id, name: `User ${id}` })
    }
    return fakeDB.get(id)!
  },
  updateUser: async (payload: User) => {
    await delay(500)
    fakeDB.set(payload.id, { id: payload.id, name: payload.name })
    return payload
  },
}
'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>
}
// 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 { 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>
  )
}

On this page

  • Introduction
  • Fetching Data
  • Optimistically Updating Query Data
  • Server Component Pattern
  • 1. Server Component (Fetching)
  • 2. Client Component
  • Streaming Reponses (Async Generators)
  • Edit this page on Github
{
  "id": "52c4be29-50dc-4574-afe5-aa3b5fd9f21a",
  "name": "User 52c4be29-50dc-4574-afe5-aa3b5fd9f21a"
}

User f5e34db0-9971-4e32-85c9-67627a838b4b