Skip to content

Realtime Experimental

RedwoodSDK provides built-in support for real-time applications through shared state synchronization. The primary way to implement this is via the useSyncedState hook.

useSyncedState looks exactly like React’s native useState, except it has bidirectional syncing with the server and all other connected clients.

  • It’s a hook that synchronizes state across multiple clients (tabs, devices, users) in real-time.
  • The server is the source of truth.
  • It allows you to build collaborative features without needing an external realtime service.
  • Realtime: Updates are instant for all users on the page.
  • Native: It’s built into Redwood SDK.
  • Cloudflare: It leverages Cloudflare Durable Objects for coordination.
  • Any component where you want data to update instantly for everyone.
  • Examples: Chat apps, collaborative forms, live dashboards, presence indicators.

Here is the easiest way to get started.

In your src/worker.tsx, you need to export the SyncedStateServer (the Durable Object) and register its routes.

src/worker.tsx
import { env } from "cloudflare:workers";
import {
SyncedStateServer,
syncedStateRoutes,
} from "rwsdk/use-synced-state/worker";
import { defineApp } from "rwsdk/worker";
// 1. Export the Durable Object so Cloudflare can find it
export { SyncedStateServer };
export default defineApp([
// ... your other middleware
// 2. Register the synced state routes
...syncedStateRoutes(() => env.SYNCED_STATE_SERVER),
]);

You need to tell Cloudflare about the Durable Object. Add the following to your wrangler.jsonc:

wrangler.jsonc
"durable_objects": {
"bindings": [
{
"name": "SYNCED_STATE_SERVER",
"class_name": "SyncedStateServer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["SyncedStateServer"]
}
]

Note: After changing wrangler.jsonc, run pnpm generate to update your types.

Now you can use useSyncedState in your components. It works just like useState, but takes a second argument: a unique key, and an optional third argument: a room ID.

src/components/SharedCounter.tsx
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const SharedCounter = () => {
// "counter" is the unique key for this piece of state
// Without a room ID, this state is global across all clients
const [count, setCount] = useSyncedState(0, "counter");
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
};

Open this component in two different browser windows. When you click increment in one, it updates in the other instantly!

By default, state is global. But you can scope state to different “rooms” by passing a room ID as the third argument. This is useful for features like chat rooms, game sessions, or collaborative documents.

src/components/RoomCounter.tsx
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const RoomCounter = ({ roomId }: { roomId: string }) => {
// State is scoped to this specific room
// Users in different rooms won't see each other's updates
const [count, setCount] = useSyncedState(0, "counter", roomId);
return (
<div>
<p>Room: {roomId}</p>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
};

When you use a room ID, state is isolated to that room. Users in "room-1" won’t see updates from users in "room-2".


Scoping State with Room IDs vs Key Handlers

Section titled “Scoping State with Room IDs vs Key Handlers”

There are two ways to scope state:

  1. Room IDs (client-side): Pass a room ID as the third argument to useSyncedState. This is the simplest way to isolate state between different groups.

  2. Key Handlers (server-side): Transform keys on the server to add prefixes or scoping logic. This is useful when you need server-enforced scoping based on authentication or other server-side data.

src/components/ChatRoom.tsx
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const ChatRoom = ({ roomId }: { roomId: string }) => {
// Each room has its own isolated state
const [messages, setMessages] = useSyncedState<string[]>([], "messages", roomId);
// ... chat UI
};

Key handlers allow you to transform keys on the server, which is useful for server-enforced scoping:

src/worker.tsx
import { requestInfo } from "rwsdk/worker";
SyncedStateServer.registerKeyHandler(async (key, stub) => {
// Access user ID from request context
const userId = requestInfo.ctx.userId;
// Scope keys that start with "user:" to the current user
if (key.startsWith("user:")) {
return `${key}:${userId}`;
}
return key;
});

Then in your component:

src/components/UserSettings.tsx
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const UserSettings = () => {
// The key handler will transform this to "user:settings:123" (where 123 is the userId)
// Each user gets their own isolated settings
const [settings, setSettings] = useSyncedState({}, "user:settings");
// ... settings UI
};

You can also transform room IDs on the server using a room handler. This is useful for features like “private” rooms that should be scoped per user:

src/worker.tsx
import { requestInfo } from "rwsdk/worker";
SyncedStateServer.registerRoomHandler(async (roomId, reqInfo) => {
const userId = reqInfo?.ctx?.userId;
// Transform "private" room requests to user-specific rooms
if (roomId === "private" && userId) {
return `user:${userId}`;
}
// Pass through other room IDs as-is
return roomId ?? "syncedState";
});

Then clients can request a “private” room, and the server will automatically scope it to the current user:

src/components/PrivateNotes.tsx
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const PrivateNotes = () => {
// Server transforms "private" to "user:${userId}" automatically
const [notes, setNotes] = useSyncedState("", "notes", "private");
// ... notes UI
};

Since the state is in-memory, you might want to save it to a database. You can register handlers for when state is set or retrieved.

src/worker.tsx
SyncedStateServer.registerSetStateHandler((key, value) => {
console.log("State updated:", key, value);
// db.save(key, value);
});
SyncedStateServer.registerGetStateHandler((key, value) => {
// potentially load from DB if value is undefined
});

We are working on making this even more powerful out of the box:

  • Offline Support: Local persistence (e.g., via IndexedDB) so your app works offline and syncs changes when the connection is restored.
  • Durable Storage: Built-in persistence to the Durable Object’s SQLite storage, ensuring state survives worker restarts without custom handlers.