Skip to main content

React Integration

The useSystem hook connects your React components to an Idyllic system. It establishes a WebSocket connection, subscribes to state updates, and returns typed state and actions. When server-side state changes, your component re-renders automatically.
import { useSystem } from '@idyllic/react';
import type { DocImprover } from '../systems/DocImprover';

function Editor() {
  const { content, improved, status, setContent, improve } = useSystem<DocImprover>();

  return (
    <div>
      <textarea value={content} onChange={e => setContent(e.target.value)} />
      <button onClick={improve}>Improve</button>
      <div>{improved.current}</div>
    </div>
  );
}
The generic parameter <DocImprover> tells TypeScript which system you’re connecting to. This enables the framework to infer types for state and actions from your system definition—full autocompletion and type checking without client-side type definitions.

What useSystem Returns

The hook returns an object containing your system’s state properties and action methods, plus connection status:
const { count, tasks, generate, status, error } = useSystem<MySystem>();
State properties (count, tasks) are reactive—reading them causes re-renders when they change. Action methods (generate) forward calls to the server over WebSocket. The status property indicates connection state, and error contains failure details if applicable.
PropertyDescription
State fieldsReactive values that trigger re-renders on change
ActionsTyped methods that execute on the server
status'connecting' | 'connected' | 'disconnected' | 'error'
errorError object if connection failed

Reading State

State values read like regular JavaScript properties. The framework tracks which properties your component accesses and re-renders when they change:
const { count, tasks, user } = useSystem<MySystem>();

<div>{count}</div>
<ul>
  {tasks.map(task => <li key={task.id}>{task.title}</li>)}
</ul>
<span>{user.name}</span>

Stream Values

For streaming content like LLM output, use the current property to display accumulated content and status to show indicators:
const { response } = useSystem<Writer>();

<div>
  {response.current}
  {response.status === 'streaming' && <span className="animate-pulse">|</span>}
</div>
Each chunk appended on the server triggers a re-render, creating the characteristic effect of tokens appearing progressively.

Calling Actions

Actions are typed proxies that forward calls to the server. Call them like regular async functions:
const { increment, createTask, analyze } = useSystem<MySystem>();

await increment(5);
await createTask("Build feature", { priority: "high" });

const result = await analyze(data);
console.log(result.score);

try {
  await dangerousOperation();
} catch (e) {
  console.error("Failed:", e.message);
}
Types for parameters and return values come from your system class. TypeScript catches mismatches at compile time.

Fire and Forget

For streaming operations, trigger the action without awaiting. The server streams chunks into state, and your component re-renders as that state updates:
function Chat() {
  const { response, generate } = useSystem<Assistant>();

  const handleSubmit = (prompt: string) => {
    generate(prompt);  // Don't await—state updates drive UI
  };

  return (
    <div>
      <button onClick={() => handleSubmit(input)} disabled={response.status === 'streaming'}>
        Send
      </button>
      <div>{response.current}</div>
    </div>
  );
}

Connection Options

Pass options to configure the connection:
useSystem<MySystem>({
  id: "user-123",                    // Which instance to connect to
  onConnect: () => console.log("Connected"),
  onDisconnect: () => console.log("Disconnected"),
  onError: (e) => console.error(e),
  autoReconnect: true,               // Default: true
  reconnectDelay: 1000,              // Milliseconds between attempts
  maxReconnectAttempts: 10,
  auth: { token: sessionToken },     // Passed to server
});

Instance IDs

The id option determines which system instance you connect to. Each unique ID corresponds to a separate Durable Object with isolated state:
const doc1 = useSystem<Editor>({ id: "doc-abc" });
const doc2 = useSystem<Editor>({ id: "doc-xyz" });
// Separate instances, separate state

const editorA = useSystem<Editor>({ id: "doc-abc" });
const editorB = useSystem<Editor>({ id: "doc-abc" });
// Same instance—collaborative editing
Use user IDs for per-user state, document IDs for per-document state, or any multi-tenant pattern.

Connection Status

Handle different connection states in your UI:
function App() {
  const { count, status, error } = useSystem<MySystem>();

  if (status === 'connecting') return <LoadingSpinner />;
  if (status === 'error') return <p>Failed: {error?.message}</p>;
  if (status === 'disconnected') return <p>Reconnecting...</p>;

  return <MainUI count={count} />;
}
Status transitions: 'connecting''connected' on success. If connection drops, 'disconnected' while reconnecting. If reconnection fails, 'error'.

Multiple Systems

Connect to multiple systems in one component. Each creates an independent WebSocket:
function Dashboard() {
  const editor = useSystem<Editor>({ id: "doc-123" });
  const analytics = useSystem<Analytics>({ id: "global" });

  return (
    <div>
      <EditorPane {...editor} />
      <AnalyticsPanel {...analytics} />
    </div>
  );
}

Type Generation

Types generate automatically during development:
npx idyllic dev      # Watches and regenerates on changes
npx idyllic generate # One-time generation
Generated types appear at .idyllic/types.ts. Import your system type and pass it to useSystem:
import type { Editor } from '../systems/Editor';
const { content, improve } = useSystem<Editor>();
Types update as you modify your system class, keeping frontend and backend in sync.

FAQ

When does useSystem create vs connect?

The hook connects to an existing instance or creates one on-demand. There’s no explicit “create”—instances materialize when the first client connects with a given ID.

What happens when the component unmounts?

The WebSocket closes. If the user navigates back, a new connection opens and receives a fresh state snapshot. Server-side execution continues regardless of client connections.

How do I share state between components?

Two options: (1) Use the same instance ID in multiple components—each gets its own connection but sees identical state. (2) Lift useSystem to a parent and pass state via props or context.

How do I test components?

Mock the hook to provide controlled state and action spies:
vi.mock('@idyllic/react', () => ({
  useSystem: () => ({
    count: 5,
    tasks: [],
    increment: vi.fn(),
    status: 'connected',
  }),
}));
For integration tests, run npx idyllic dev and use actual connections.

Guides

Learn patterns for building with Idyllic