@blujosi/rivetkit-svelte
v2.3.13
Published
A Svelte 5 integration for [RivetKit](https://rivet.dev) that provides reactive actor connections using Svelte 5's runes system, plus a built-in SvelteKit handler for serverless deployment.
Readme
@blujosi/rivetkit-svelte
A Svelte 5 integration for RivetKit that provides reactive actor connections using Svelte 5's runes system, plus a built-in SvelteKit handler for serverless deployment.
Installation
pnpm add @blujosi/rivetkit-svelte rivetkit
# or
npm i @blujosi/rivetkit-svelte rivetkitOverview
@blujosi/rivetkit-svelte provides two main pieces:
- Svelte 5 client (
@blujosi/rivetkit-svelte) —useActorhook for reactive actor connections with real-time events - SvelteKit handler (
@blujosi/rivetkit-svelte/sveltekit) —createRivetKitHandlerto serve RivetKit as a SvelteKit API route
Features
- Svelte 5 Runes — Built for
$state,$effect, and$derived - Real-time Actor Connections — Connect to RivetKit actors with automatic state sync
- Event Handling —
useEventwith automatic cleanup - CRUD Transforms — Pre-built transform factories for managing lists via create/update/delete events
- Type Safety — Full TypeScript support with registry type inference
- SSR Compatible — Browser guard for SvelteKit SSR
- SvelteKit Handler — Run RivetKit serverless inside your SvelteKit app
Quick Start
1. Define Your Actors & Registry
// backend/registry.ts
import { actor, setup } from "rivetkit";
export const counter = actor({
state: { count: 0, countDouble: 0 },
actions: {
increment: (c, x: number) => {
c.state.count += x;
c.broadcast("newCount", c.state.count);
return c.state.count;
},
getCount: (c) => c.state.count,
getCountDouble: (c) => c.state.countDouble,
doubleIncrement: (c, y: number) => {
c.state.countDouble += y;
c.broadcast("newDoubleCount", c.state.countDouble);
return c.state.countDouble;
},
reset: (c) => {
c.state.count = 0;
c.state.countDouble = 0;
c.broadcast("newCount", c.state.count);
c.broadcast("newDoubleCount", c.state.countDouble);
return c.state.count;
},
},
});
export const registry = setup({
use: { counter },
});
export type Registry = typeof registry;2. Set Up the SvelteKit Handler
Create a catch-all API route to proxy RivetKit requests through SvelteKit:
// src/routes/api/rivet/[...rest]/+server.ts
import { createRivetKitHandler } from "@blujosi/rivetkit-svelte/sveltekit";
import { dev } from "$app/environment";
import { registry } from "$backend/registry";
export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } =
createRivetKitHandler({
isDev: !!dev,
registry,
rivetSiteUrl: "http://localhost:5173",
});The handler automatically:
- Spawns the RivetKit engine in dev mode
- Configures the serverless runner pool
- Proxies requests to the registry's built-in handler
Handler Options
| Option | Type | Description |
|---|---|---|
| registry | Registry | Your RivetKit registry instance |
| isDev | boolean | Enables auto-engine spawn and runner pool config |
| rivetSiteUrl | string? | Base URL for the site. Falls back to PUBLIC_RIVET_ENDPOINT env var |
| headers | Record<string, string>? | Static headers added to every request |
| getHeaders | (event: RequestEvent) => Record<string, string>? | Dynamic per-request headers |
| runtime | "default" \| "cloudflare"? | Runtime to use. "default" uses the built-in registry handler; "cloudflare" delegates to @rivetkit/cloudflare-workers. Defaults to "default" |
Cloudflare Workers Runtime
To deploy your SvelteKit app on Cloudflare Workers, set runtime: "cloudflare" and install the Cloudflare Workers adapter:
npm install @rivetkit/cloudflare-workersThen update your handler:
// src/routes/api/rivet/[...rest]/+server.ts
import { createRivetKitHandler } from "@blujosi/rivetkit-svelte/sveltekit";
import { dev } from "$app/environment";
import { registry } from "$backend/registry";
export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } =
createRivetKitHandler({
isDev: !!dev,
registry,
rivetSiteUrl: "http://localhost:5173",
runtime: "cloudflare",
});When runtime: "cloudflare" is set, the handler dynamically imports @rivetkit/cloudflare-workers and uses its createHandler to process requests via Cloudflare's Durable Objects. The SvelteKit adapter automatically passes event.platform.env and event.platform.ctx from the Cloudflare Workers environment. The @rivetkit/cloudflare-workers package is an optional peer dependency — it only needs to be installed when using the Cloudflare runtime.
3. Create the Client
// src/lib/actor.client.ts
import { createClient, createRivetKitWithClient } from "@blujosi/rivetkit-svelte";
import type { Client } from "rivetkit/client";
import { browser } from "$app/environment";
import type { Registry } from "$backend/registry";
let rivetClient: Client<Registry> | undefined;
export const getRivetClient = () => {
if (!browser) return { useActor: () => {} };
const origin = `${location.origin}/api/rivet`;
rivetClient = createClient<Registry>(origin);
const { useActor } = createRivetKitWithClient(rivetClient);
return { rivetClient, useActor };
};Important: The client must only be created in the browser. The
browserguard ensures SSR doesn't attempt a connection.
4. Use Actors in Svelte Components
The simplest approach is useActionQuery — it fetches the value by calling an action, then re-fetches whenever an event fires:
<!-- src/routes/+page.svelte -->
<script lang="ts">
import { getRivetClient } from "../lib/actor.client";
import { browser } from "$app/environment";
const { useActor } = getRivetClient();
const counterActor = browser
? useActor({ name: "counter", key: ["test-counter"] })
: undefined;
// useActionQuery: fetches value, re-fetches on event
const count = counterActor?.useActionQuery({
action: "getCount",
event: "newCount",
initialValue: 0,
});
const countDouble = counterActor?.useActionQuery({
action: "getCountDouble",
event: "newDoubleCount",
initialValue: 0,
});
// Call actions through the connection
const increment = async () => {
await counterActor?.current?.connection?.increment(1);
};
const reset = async () => {
await counterActor?.current?.connection?.reset();
};
const doubleCountClick = async () => {
await counterActor?.current?.connection?.doubleIncrement(2);
};
</script>
<div>
<h1>Counter: {count?.value ?? 0}</h1>
<button onclick={increment}>Increment</button>
<button onclick={reset}>Reset</button>
<h1>Counter 2: {countDouble?.value ?? 0}</h1>
<button onclick={doubleCountClick}>Double Count</button>
</div>Alternatively, you can use useEvent directly for manual control:
<script lang="ts">
let count = $state(0);
// Listen for events (call at top-level, NOT inside $effect)
counterActor?.useEvent("newCount", (x: number) => {
count = x;
});
</script>API Reference
Client Exports (@blujosi/rivetkit-svelte)
createClient<Registry>(url)
Creates a RivetKit client connection.
import { createClient } from "@blujosi/rivetkit-svelte";
const client = createClient<Registry>("http://localhost:5173/api/rivet");createRivetKit<Registry>(url, opts?)
Shorthand that creates both the client and the useActor hook.
import { createRivetKit } from "@blujosi/rivetkit-svelte";
export const { useActor } = createRivetKit<Registry>("http://localhost:5173/api/rivet");createRivetKitWithClient<Registry>(client, opts?)
Creates the useActor hook from an existing client instance. Use this when you need access to the client elsewhere.
import { createClient, createRivetKitWithClient } from "@blujosi/rivetkit-svelte";
const client = createClient<Registry>(url);
export const { useActor } = createRivetKitWithClient(client);useActor(options)
Connects to a RivetKit actor and returns reactive state. Must be called at the top level of a component script (not inside onMount or other callbacks) so runes attach correctly.
const actor = useActor({
name: "counter", // Actor name from your registry
key: ["test-counter"], // Unique key for this instance
params: { /* ... */ }, // Optional parameters
createInRegion: "us-east-1", // Optional region
createWithInput: { /* */ }, // Optional input data
enabled: true, // Optional, defaults to true
});Returns:
| Property | Type | Description |
|---|---|---|
| current.connection | ActorConn | Call actions on the actor |
| current.handle | ActorHandle | Advanced actor operations |
| current.isConnected | boolean | Whether the actor is connected |
| current.isConnecting | boolean | Whether a connection is in progress |
| current.isError | boolean | Whether there's an error |
| current.error | Error \| null | The error object, if any |
| useEvent(name, handler) | function | Listen for actor events |
| useQuery(opts) | object | Reactive query with transform — see below |
| useActionQuery(opts) | object | Re-fetch query (event = invalidation signal) — see below |
useEvent(eventName, handler)
Registers an event listener with automatic cleanup. Call at the top level of the component script, not inside $effect.
counterActor?.useEvent("newCount", (value: number) => {
count = value;
});useQuery(options)
Creates a reactive query that fetches an initial value by calling an actor action, then subscribes to an event to keep the value updated in real-time. This combines useEvent + an initial action call into a single API.
Call at the top level of the component script, not inside $effect or onMount.
const count = counterActor?.useQuery({
action: "getCount", // Action to call for the initial value
event: "newCount", // Event to subscribe to for updates
initialValue: 0, // Value before the action resolves
args: [], // Optional arguments to pass to the action
});Options:
| Option | Type | Description |
|---|---|---|
| action | string | The action name to call for the initial value |
| args | any[]? | Optional arguments passed to the action |
| event | string | The event name to subscribe to for real-time updates |
| initialValue | T | The value to use before the action resolves |
| transform | (current: T, incoming: any) => T? | Optional function to merge incoming event data with the current value. For CRUD lists, use crudTransform<T>() |
Default transform behavior:
- Plain objects — shallow merge:
{ ...current, ...incoming } - Primitives & arrays — full replacement
Returns:
| Property | Type | Description |
|---|---|---|
| value | T | The current reactive value |
| isLoading | boolean | true until the initial action resolves |
| error | Error \| null | Error from the action call, if any |
Example with action arguments:
// If your getter action takes parameters:
const filteredItems = counterActor?.useQuery({
action: "getItems",
args: ["active", 10], // passed as getItems("active", 10)
event: "itemsUpdated",
initialValue: [],
});Object state without custom transform (default shallow merge):
<script lang="ts">
// Default: objects are shallow-merged, primitives are replaced
const gameState = gameActor?.useQuery({
action: "getState",
event: "stateUpdated",
initialValue: { players: [], score: 0, phase: "lobby" },
});
// If the actor broadcasts { score: 5 }, the value becomes:
// { players: [], score: 5, phase: "lobby" } ← players & phase preserved
</script>
<p>Score: {gameState?.value.score}</p>
<p>Phase: {gameState?.value.phase}</p>Object state with custom transform (e.g. deep merge for nested arrays):
<script lang="ts">
const gameState = gameActor?.useQuery({
action: "getState",
event: "stateUpdated",
initialValue: { players: [], score: 0, phase: "lobby" },
transform: (current, incoming) => ({
...current,
...incoming,
// Append new players instead of replacing the array
players: incoming.players
? [...current.players, ...incoming.players]
: current.players,
}),
});
// If the actor broadcasts { players: [{ name: "Alice" }] }, the value becomes:
// { players: [...existing, { name: "Alice" }], score: 0, phase: "lobby" }
</script>Full replacement transform (override the default merge):
const gameState = gameActor?.useQuery({
action: "getState",
event: "stateUpdated",
initialValue: { players: [], score: 0, phase: "lobby" },
// Always replace entirely
transform: (_current, incoming) => incoming,
});useActionQuery(options)
Creates a reactive query that calls an actor action to fetch data, then re-calls the same action whenever one of the specified events fires or the args change. Unlike useQuery, event data is ignored — the event is purely an invalidation signal.
This is the recommended approach for most use-cases: simpler, always consistent with server state, and no transform logic to maintain.
Call at the top level of the component script, not inside $effect or onMount.
const count = counterActor?.useActionQuery({
action: "getCount", // Action to call (and re-call)
event: "newCount", // Event(s) that trigger a refetch
initialValue: 0, // Value before the first action resolves
});Options:
| Option | Type | Description |
|---|---|---|
| action | string | The action name to call |
| args | () => any[]? | Reactive getter returning arguments. When the return value changes, the action is re-called |
| event | string \| string[] | Event name(s) that trigger a refetch |
| initialValue | T | The value to use before the first action resolves |
Returns:
| Property | Type | Description |
|---|---|---|
| value | T | The current reactive value |
| isLoading | boolean | true while an action call is in flight |
| error | Error \| null | Error from the action call, if any |
| refetch() | function | Manually trigger a re-fetch |
Basic usage:
<script lang="ts">
const count = counterActor?.useActionQuery({
action: "getCount",
event: "newCount",
initialValue: 0,
});
</script>
<p>Count: {count?.value}</p>Multiple invalidation events:
// Re-fetch whenever *any* of these events fire
const count = counterActor?.useActionQuery({
action: "getCount",
event: ["newCount", "countReset", "countBatchUpdate"],
initialValue: 0,
});Reactive args — re-fetches when args change:
<script lang="ts">
let filter = $state("active");
let limit = $state(10);
const items = counterActor?.useActionQuery({
action: "getItems",
args: () => [filter, limit], // re-fetches when filter or limit changes
event: "itemsUpdated",
initialValue: [],
});
</script>
<select bind:value={filter}>
<option value="active">Active</option>
<option value="archived">Archived</option>
</select>
<p>{items?.value.length} items</p>Manual refetch:
const count = counterActor?.useActionQuery({
action: "getCount",
event: "newCount",
initialValue: 0,
});
// Programmatically re-fetch at any time
count?.refetch();useActionQuery vs useQuery — when to use which:
| | useActionQuery | useQuery |
|---|---|---|
| Event data | Ignored (just a signal) | Used directly via transform |
| On event | Re-calls the action | Merges event payload into value |
| Args | Reactive (re-fetches on change) | Static |
| Transform | Not needed | Optional (default: shallow merge) |
| Best for | Most use-cases | High-frequency events where refetch would be wasteful |
| Refetch | .refetch() available | Not available |
CRUD Transforms (@blujosi/rivetkit-svelte)
Pre-built transform factories for managing lists of items via create/update/delete events. Use with useQuery or rivetLoad.
import {
crudTransform,
createTransform,
updateTransform,
deleteTransform,
} from "@blujosi/rivetkit-svelte";crudTransform<T>(opts?)
A unified transform that handles all three CRUD operations in a single function. The actor broadcasts events wrapped in a CrudEvent<T>:
// In the actor:
c.broadcast("taskChanged", { type: "created", data: newTask });
c.broadcast("taskChanged", { type: "updated", data: updatedTask });
c.broadcast("taskChanged", { type: "deleted", data: deletedTask });<script lang="ts">
import { crudTransform } from "@blujosi/rivetkit-svelte";
interface Task { id: string; title: string; done: boolean }
// With useQuery (client-side) — T is the full state type (Task[])
const tasks = actor?.useQuery<Task[]>({
action: "getTasks",
event: "taskChanged",
initialValue: [] as Task[],
transform: crudTransform<Task>({ key: "id" }),
});
</script>// With rivetLoad (SSR + live) — T is the item type, data returns Task[]
const tasks = await rivetLoad<Registry, Task>(client, {
actor: "taskList",
key: ["my-list"],
action: "getTasks",
event: "taskChanged",
transform: crudTransform<Task>({ key: "id" }),
});If the incoming payload is not wrapped in { type, data }, crudTransform falls back to an upsert — it updates the item if it exists, or appends it otherwise.
Options (CrudTransformOptions<T>):
| Option | Type | Default | Description |
|---|---|---|---|
| key | keyof T \| (item: T) => unknown | "id" | Property name or accessor used to uniquely identify items |
CrudEvent<T> shape:
| Field | Type | Description |
|---|---|---|
| type | "created" \| "updated" \| "deleted" | The operation type |
| data | T | The item (or key value for deletes) |
Individual Transforms
Use these when you have separate events for each operation:
createTransform<T>(opts?)
Appends the incoming item to the list. Duplicates (same key) are ignored.
const tasks = actor?.useQuery({
action: "getTasks",
event: "taskCreated",
initialValue: [],
transform: createTransform<Task>({ key: "id" }),
});updateTransform<T>(opts?)
Replaces the matching item in-place. If no match is found, the list is returned unchanged.
const tasks = actor?.useQuery({
action: "getTasks",
event: "taskUpdated",
initialValue: [],
transform: updateTransform<Task>({ key: "id" }),
});deleteTransform<T>(opts?)
Removes the matching item. Accepts either a full object or a raw key value.
const tasks = actor?.useQuery({
action: "getTasks",
event: "taskDeleted",
initialValue: [],
transform: deleteTransform<Task>({ key: "id" }),
});
// The actor can broadcast just the key:
c.broadcast("taskDeleted", taskId);
// Or the full object:
c.broadcast("taskDeleted", task);Custom Key Functions
For items keyed by something other than a single property:
const items = actor?.useQuery<Item[]>({
action: "getItems",
event: "itemChanged",
initialValue: [],
transform: crudTransform<Item>({
key: (item) => `${item.type}:${item.slug}`,
}),
});SvelteKit Exports (@blujosi/rivetkit-svelte/sveltekit)
createRivetKitHandler(opts)
Creates SvelteKit request handlers for all HTTP methods. Every incoming request to the catch-all route is forwarded to the RivetKit registry handler.
import { createRivetKitHandler } from "@blujosi/rivetkit-svelte/sveltekit";
export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } =
createRivetKitHandler({ isDev: true, registry, rivetSiteUrl: "http://localhost:5173" });Options:
| Option | Type | Description |
|---|---|---|
| registry | Registry | Your RivetKit registry instance |
| isDev | boolean | Enables auto-engine spawn and runner pool config |
| rivetSiteUrl | string? | Base URL for the site. Falls back to PUBLIC_RIVET_ENDPOINT env var |
| headers | Record<string, string>? | Static headers added to every request sent to the registry handler |
| getHeaders | (event: RequestEvent) => Record<string, string>? | Dynamic per-request headers. Receives the full SvelteKit RequestEvent |
Passing Custom Headers (Authentication, JWT Tokens, etc.)
The headers and getHeaders options let you inject headers into every request forwarded to your RivetKit actors. This is essential for passing JWT tokens, session IDs, or any other authentication data from your SvelteKit application to your actors so they can verify and authorize requests.
Since getHeaders receives the full SvelteKit RequestEvent, you have access to locals, cookies, url, params, and anything else set by your hooks or middleware — making it the ideal place to forward auth context.
Pass a JWT token from locals (set by your auth hook):
// src/routes/api/rivet/[...rest]/+server.ts
import { createRivetKitHandler } from "@blujosi/rivetkit-svelte/sveltekit";
import { dev } from "$app/environment";
import { registry } from "$backend/registry";
export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } =
createRivetKitHandler({
isDev: !!dev,
registry,
rivetSiteUrl: "http://localhost:5173",
// Forward auth token from locals (populated in hooks.server.ts)
getHeaders: (event) => ({
"x-app-token": event.locals.token ?? "",
}),
});Your actors can then read x-app-token from the incoming request headers to authenticate and authorize the caller.
Combine static and dynamic headers:
createRivetKitHandler({
isDev: !!dev,
registry,
rivetSiteUrl: "http://localhost:5173",
// Static headers — same on every request
headers: {
"x-api-version": "2",
"x-app-name": "my-app",
},
// Dynamic headers — per-request, from SvelteKit locals/cookies
getHeaders: (event) => ({
"x-app-token": event.locals.token ?? "",
"x-user-id": event.locals.user?.id ?? "",
"x-session-id": event.cookies.get("session_id") ?? "",
}),
});Static headers are applied first, then getHeaders — so dynamic headers can override static ones if they share the same key. Both are set on every request that passes through the handler.
SSR → Live Query Transport
@blujosi/rivetkit-svelte supports server-side rendering of actor data that seamlessly upgrades to live WebSocket subscriptions on the client — zero loading flash, instant first paint, then real-time forever.
This uses SvelteKit's built-in transport hook to serialize actor query results across the SSR boundary.
How it works
rivetLoad()in your+page.tsload function calls an actor action via stateless HTTP on the server- SvelteKit's
transporthook serializes the result across the SSR boundary - On the client,
transport.decodecreates a live WebSocket subscription with the SSR data as initial state - On client-side navigation,
rivetLoad()detects the browser and creates the subscription directly - Events from the actor push updates to the reactive query — no manual refresh needed
Setup
1. Add the transport hook
// src/hooks.ts
import { encodeRivetLoad, decodeRivetLoad } from "@blujosi/rivetkit-svelte/sveltekit"
import { rivetClient } from "$lib/actor.client"
export const transport = {
RivetLoadResult: {
encode: (value) => encodeRivetLoad(value),
decode: (encoded) => decodeRivetLoad(encoded, rivetClient),
},
}2. Use rivetLoad() in your load function
rivetLoad<T> is designed for collections — T is the item type and data returns T[]. The default transform is crudTransform<T>(), so standard CRUD events work out of the box.
// src/routes/todos/+page.ts
import { rivetLoad } from "@blujosi/rivetkit-svelte/sveltekit"
import { rivetClient } from "$lib/actor.client"
import type { Registry } from "$backend/registry"
interface Todo { id: string; text: string; done: boolean }
export const load = async () => ({
todos: await rivetLoad<Registry, Todo>(rivetClient, {
actor: 'todoList',
key: ['my-list'],
action: 'getTodos',
event: 'todoChanged',
})
})3. Use the data in your component
<!-- src/routes/todos/+page.svelte -->
<script lang="ts">
let { data } = $props()
// data.todos is already a reactive RivetQueryResult
// It has SSR data immediately, then upgrades to live updates
const todos = $derived(data.todos.data)
</script>
{#if data.todos.isLoading}
<p>Loading...</p>
{:else}
<ul>
{#each todos ?? [] as todo}
<li>{todo.text}</li>
{/each}
</ul>
{/if}That's it. The page renders with SSR data on first paint, SvelteKit preloads on link hover, and then the actor connection keeps data live in real-time.
rivetLoad(client, options)
Fetch actor data for use in SvelteKit load functions. Dual-mode:
- Server (SSR): calls the action via stateless HTTP, wraps result for transport
- Client (navigation): calls action for initial data, then creates a live subscription immediately
rivetLoad<T> is designed for collections — T is the item type and data returns T[]. The default transform is crudTransform<T>(), so standard CRUD events work without specifying a transform.
interface Todo { id: string; text: string; done: boolean }
const result = await rivetLoad<Registry, Todo>(rivetClient, {
actor: 'todoList',
key: ['my-list'],
action: 'getTodos',
event: 'todoChanged',
args: [], // optional action arguments
params: { authToken: 'jwt-...' }, // optional connection params
// transform defaults to crudTransform<T>() — override if needed:
// transform: (current, incoming) => [...current, incoming.data],
})Options:
| Option | Type | Description |
|---|---|---|
| actor | string | Actor name from your registry |
| key | string \| string[] | Unique key for the actor instance |
| action | string | Action name to call for the initial value |
| event | string \| string[] | Event name(s) to subscribe to for live updates |
| args | unknown[]? | Optional arguments passed to the action |
| params | Record<string, string>? | Optional connection parameters |
| createInRegion | string? | Region to create the actor in |
| createWithInput | unknown? | Input data for actor creation |
| transform | (current: T[], incoming: CrudEvent<T>) => T[]? | Transform incoming event data. Default: crudTransform<T>() |
Returns: RivetQueryResult<T> — a reactive object with:
| Property | Type | Description |
|---|---|---|
| data | T[] \| undefined | The current value (collection of items) |
| isLoading | boolean | true while loading |
| error | Error \| undefined | Error, if any |
| isConnected | boolean | Whether the live connection is active |
encodeRivetLoad(value) / decodeRivetLoad(encoded, client, transform?)
Transport encode/decode functions for src/hooks.ts. Wire them into SvelteKit's transport hook to enable SSR → live query upgrade.
// src/hooks.ts
import { encodeRivetLoad, decodeRivetLoad } from "@blujosi/rivetkit-svelte/sveltekit"
import { rivetClient } from "$lib/actor.client"
export const transport = {
RivetLoadResult: {
encode: (value) => encodeRivetLoad(value),
decode: (encoded) => decodeRivetLoad(encoded, rivetClient),
},
}Note:
decodeRivetLoadaccepts an optional third argumenttransformif you need to customize how event data is applied. By default, the transform iscrudTransform<T>(), which handlesCrudEvent<T>payloads ({ type: "created" | "updated" | "deleted", data: T }).
Multiple queries in one load
// src/routes/+page.ts
import type { Registry } from "$backend/registry"
interface Todo { id: string; text: string; done: boolean }
interface Comment { id: string; todoId: string; body: string }
export const load = async () => ({
todos: await rivetLoad<Registry, Todo>(rivetClient, {
actor: 'todoList',
key: ['my-list'],
action: 'getTodos',
event: 'todoChanged',
}),
comments: await rivetLoad<Registry, Comment>(rivetClient, {
actor: 'todoList',
key: ['my-list'],
action: 'getComments',
event: 'commentChanged',
}),
})Using with useActor for mutations
SSR data gives you read access. For mutations (calling actions that change state), combine with useActor:
<script lang="ts">
import { useActor } from "$lib";
let { data } = $props();
// Read: SSR data with live updates (T[] collection)
const todos = $derived(data.todos.data);
// Write: useActor for action calls
const todoActor = useActor?.({
name: "todoList",
key: ["my-list"],
});
const addTodo = async () => {
await todoActor?.current?.connection?.addTodo({ text: "New task" });
};
</script>
<ul>
{#each todos ?? [] as todo}
<li>{todo.text}</li>
{/each}
</ul>
<button onclick={addTodo}>Add Todo</button>
```typescript
// BAD
onMount(() => {
const actor = useActor({ name: "counter", key: ["test"] });
});
// GOOD
const actor = browser
? useActor({ name: "counter", key: ["test"] })
: undefined;Don't call useEvent inside $effect
useEvent manages its own internal effects. Wrapping it in another $effect causes duplicate listeners and broken state.
// BAD
$effect(() => {
actor?.useEvent("newCount", (x) => { count = x; });
});
// GOOD
actor?.useEvent("newCount", (x) => { count = x; });Don't call .connect() on the connection
The connection is automatically managed by useActor. Calling .connect() sends an RPC action named "connect" to your actor, which doesn't exist.
// BAD
await actor?.current?.connection?.connect();
// GOOD — just call actions directly
await actor?.current?.connection?.increment(1);License
MIT
Inspired by the Rivet core implementation for React and Next.js.
