Realtime
ExperimentalA tutorial on how to use the useSyncedState hook for realtime shared state.
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.
What is it?
- 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.
Why would you use it?
- Realtime: Updates are instant for all users on the page.
- Native: It's built into RedwoodSDK.
- Cloudflare: It leverages Cloudflare Durable Objects for coordination.
Where would you use it?
- Any component where you want data to update instantly for everyone.
- Examples: Chat apps, collaborative forms, live dashboards, presence indicators.
This is a low-level primitive. Currently, all values are stored in memory within the Durable Object. If the Durable Object is evicted or the worker restarts, the state is wiped. However, you can add callbacks to persist data to a database if needed.
Tutorial: From 0 to 1
Here is the easiest way to get started.
1. Setup the Worker
In your src/worker.tsx, you need to export the SyncedStateServer (the Durable Object) and register its routes.
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),
]);2. Update Wrangler Config
You need to tell Cloudflare about the Durable Object. Add the following to your wrangler.jsonc:
"durable_objects": {
"bindings": [
{
"name": "SYNCED_STATE_SERVER",
"class_name": "SyncedStateServer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["SyncedStateServer"]
}
]Note: After changing
wrangler.jsonc, runpnpm generateto update your types.
3. Use the Hook
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.
"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!
Rooms: Scoping State to Different Groups
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.
"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".
Advanced: Scoping and Persistence
Scoping State with Room IDs vs Key Handlers
There are two ways to scope state:
-
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. -
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.
Using Room IDs (Client-Side)
"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
};Using Key Handlers (Server-Side)
Key handlers allow you to transform keys on the server, which is useful for server-enforced scoping:
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:
"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
};Server-Side Room Transformation
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:
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:
"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
};Persisting State
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.
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
});Future Plans
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.