Crux
GuidesPlans & Tasks

Reactive UI

Real-time hooks for plan and task progress in the browser, with any transport backend.

The Problem

Agents work in the background. The UI needs to show what's happening — which tasks are in progress, what the current plan looks like, how close the work is to completion. Without reactive updates, you're stuck polling or building custom WebSocket plumbing.

The Solution

Crux provides domain hooks that work with any transport. The hooks subscribe to store changes and re-render automatically:

task-progress.tsx
import { useTaskList, useTasks } from '@crux/react'

function TaskProgress({ taskListId }: { taskListId: string }) {
  const taskList = useTaskList(taskListId)
  const tasks = useTasks(taskListId)

  if (!taskList) return <Loading />

  return (
    <div>
      <p>Status: {taskList.status}</p>
      {tasks?.map((task) => (
        <div key={task.id}>
          <span>{task.label}</span>
          <span>{task.status}</span>
          {task.progress && <span>{task.progress}</span>}
        </div>
      ))}
    </div>
  )
}

Available Hooks

usePlan

Reactive plan data. Re-renders when the plan is created, updated, or deleted.

import { usePlan } from '@crux/react'

function PlanView({ planId }: { planId: string }) {
  const plan = usePlan(planId)

  if (!plan) return <Loading />

  return (
    <div>
      <h2>{plan.title}</h2>
      <p>Version: {plan.version}</p>
      <div>{plan.content}</div>
    </div>
  )
}

useTaskList

Reactive task list metadata. Query by ID or by plan association:

import { useTaskList } from '@crux/react'

// By task list ID
const taskList = useTaskList(taskListId)

// By plan ID — finds the task list linked to this plan
const taskList = useTaskList({ planId })

useTasks

Reactive task array. Excludes removed tasks and auto-updates on any task mutation:

import { useTasks } from '@crux/react'

const tasks = useTasks(taskListId)

Skip Pattern

Pass undefined to any hook to skip the query. This is useful for conditional rendering when the entity hasn't been created yet:

function TaskView({ taskListId }: { taskListId: string | undefined }) {
  const tasks = useTasks(taskListId) // skips if undefined

  if (!tasks) return <p>Waiting for tasks...</p>

  return <TaskList tasks={tasks} />
}

Transport Setup

Hooks need a transport to know how to subscribe to changes. Wrap your app with <CruxProvider>:

import { CruxProvider } from '@crux/react'

function App() {
  return (
    <CruxProvider transport={transport}>
      <YourApp />
    </CruxProvider>
  )
}

Transport Options

TransportBest forReal-time?
ConvexConvex backends — native reactivityYes
SSEAny backend with SSE endpointYes
AI SDK streamActive useChat streamsDuring stream
PollingSimple REST backendsNear real-time
MockTests and StorybookControlled

Convex Transport

If your backend is Convex, the store is natively reactive — no extra setup beyond the standard Convex provider:

import { CruxProvider } from '@crux/react'
import { createConvexTransport } from '@crux/convex/react'
import { useQuery } from 'convex/react'
import { components } from '../convex/_generated/api'

function App() {
  const transport = createConvexTransport({
    api: components.crux,
    useQuery,
  })

  return (
    <CruxProvider transport={transport}>
      <YourApp />
    </CruxProvider>
  )
}

SSE Transport

Connect to a server-sent events endpoint:

import { CruxProvider, createSSETransport } from '@crux/react'

function App() {
  const transport = createSSETransport('/api/crux/sse')

  return (
    <CruxProvider transport={transport}>
      <YourApp />
    </CruxProvider>
  )
}

See Server-Side Integration for setting up the SSE endpoint.

Polling Transport

For backends without real-time support:

import { CruxProvider, createPollingTransport } from '@crux/react'

function App() {
  const transport = createPollingTransport('/api/crux', { intervalMs: 2000 })

  return (
    <CruxProvider transport={transport}>
      <YourApp />
    </CruxProvider>
  )
}

AI SDK Stream Transport

For streaming plan/task updates through an active AI SDK chat stream:

import { CruxProvider } from '@crux/react'
import { createStreamTransport } from '@crux/ai/stream'
import { useChat } from 'ai/react'

function ChatWithTasks() {
  const transport = createStreamTransport()

  const { messages } = useChat({
    api: '/api/chat',
    onData: (part) => transport.ingest(part), // connects stream to transport
  })

  return (
    <CruxProvider transport={transport}>
      <TaskProgress taskListId={currentTaskListId} />
    </CruxProvider>
  )
}

The ingest() method feeds stream data parts into the transport. When the server writes data-crux parts via createCruxStreamWriter, they flow through useChat's onData callback into the transport, which updates the reactive hooks.

See Server-Side Integration for the server-side stream writer setup.

Mock Transport

For tests and Storybook:

import { CruxProvider, createMockTransport } from '@crux/react'

const transport = createMockTransport({
  plans: { 'plan-1': { id: 'plan-1', title: 'Test Plan', content: '...', version: 1 } },
  taskLists: { 'tl-1': { id: 'tl-1', status: 'in_progress' } },
  tasks: { 'tl-1': [{ id: 'research', label: 'Research', status: 'completed' }] },
})

function TestWrapper({ children }: { children: React.ReactNode }) {
  return <CruxProvider transport={transport}>{children}</CruxProvider>
}

Combining Plan and Task UI

A complete progress view combining plan content with task tracking:

progress-view.tsx
import { usePlan, useTaskList, useTasks } from '@crux/react'

function AgentProgress({ planId }: { planId: string }) {
  const plan = usePlan(planId)
  const taskList = useTaskList({ planId })
  const tasks = useTasks(taskList?.id)

  if (!plan) return <Loading />

  const completed = tasks?.filter((t) => t.status === 'completed').length ?? 0
  const total = tasks?.length ?? 0

  return (
    <div>
      <h2>{plan.title}</h2>
      <p>{plan.content}</p>

      {taskList && (
        <div>
          <p>
            Progress: {completed}/{total} tasks
          </p>
          <p>Status: {taskList.status}</p>

          {tasks?.map((task) => (
            <div key={task.id}>
              <span>{task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '⟳' : '○'}</span>
              <span>{task.label}</span>
              {task.progress && <em>{task.progress}</em>}
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

On this page