Currency transfers example
This example shows a complete flow for currency transfers: a client-side collection hook that syncs over WebSockets and a DO RPC server that owns the authoritative currency state.
Client: useCurrencyCollectionHook
Section titled “Client: useCurrencyCollectionHook”This hook creates a TanStack DB collection, wires outbound mutations to RPC calls, and listens for inbound events to update local state.
import { createCollection, localStorageCollectionOptions } from '@tanstack/db';import { createSubscriptionClient } from '@repo/client-side-do';import { useEffect, useMemo } from 'react';
type CurrencyRecord = { inventoryId: string; platinum: number; gold: number; electrum: number; silver: number; copper: number;};
type CurrencyRpc = { updateCurrency: (args: { currency: Partial<CurrencyRecord>; }) => Promise<CurrencyRecord>; transferCurrency: (args: { transferId: string; fromInventoryId: string; toInventoryId: string; currency: Partial<CurrencyRecord>; }) => Promise<{ transferId: string; fromInventoryId: string; toInventoryId: string; currency: Partial<CurrencyRecord>; }>;};
type CurrencyAction = | { type: 'updateCurrency'; payload: Parameters<CurrencyRpc['updateCurrency']>[0]; } | { type: 'transferCurrency'; payload: Parameters<CurrencyRpc['transferCurrency']>[0]; };
const currencyCollection = createCollection<CurrencyRecord>( localStorageCollectionOptions({ storageKey: 'currency-db', getKey: (record) => record.inventoryId, }));
const buildWsUrl = (inventoryId: string) => `wss://example.com/api/inventory/${inventoryId}/ws`;
export function useCurrencyCollectionHook(inventoryIds: string[]) { const buildSubscriptionClient = useMemo(() => { return ( id: string, handlers: Parameters< ReturnType<typeof createSubscriptionClient<CurrencyRpc>> >[1] ) => createSubscriptionClient<CurrencyRpc>({ baseUrl: buildWsUrl(id) })( id, handlers ); }, []);
const clientsById = useMemo(() => { return new Map( inventoryIds.map((id) => { const client = buildSubscriptionClient(id, {} as never); return [id, client] as const; }) ); }, [inventoryIds, buildSubscriptionClient]);
useEffect(() => { const handleInsert = async (record: CurrencyRecord, metadata?: unknown) => { const action = (metadata as { action?: CurrencyAction })?.action; if (!action) { return; } const client = clientsById.get(record.inventoryId); if (!client) { return; } if (action.type === 'updateCurrency') { await client.client.updateCurrency(action.payload); } if (action.type === 'transferCurrency') { await client.client.transferCurrency(action.payload); } };
const unsubscribe = currencyCollection.subscribe( ({ event, record, metadata }) => { if (event === 'insert' || event === 'update') { void handleInsert(record, metadata); } } );
return () => unsubscribe(); }, [clientsById]);
useEffect(() => { const unsubscribers: Array<() => void> = []; clientsById.forEach((client, inventoryId) => { const handlers = { updateCurrency: ({ result }: { result?: CurrencyRecord }) => { if (!result) { return; } const record = { inventoryId, ...result }; if (!currencyCollection.get(record.inventoryId)) { currencyCollection.insert(record); } currencyCollection.update(record.inventoryId, (draft) => ({ ...draft, ...record, })); }, transferCurrency: ({ result, }: { result?: CurrencyRpc['transferCurrency'] extends ( ...args: never[] ) => infer R ? Awaited<R> : never; }) => { if (!result) { return; } const fromRecord = currencyCollection.get(result.fromInventoryId); if (fromRecord) { currencyCollection.update(result.fromInventoryId, (draft) => { draft.platinum -= result.currency.platinum ?? 0; draft.gold -= result.currency.gold ?? 0; draft.electrum -= result.currency.electrum ?? 0; draft.silver -= result.currency.silver ?? 0; draft.copper -= result.currency.copper ?? 0; }); } if (!currencyCollection.get(result.toInventoryId)) { currencyCollection.insert({ inventoryId: result.toInventoryId, platinum: 0, gold: 0, electrum: 0, silver: 0, copper: 0, }); } currencyCollection.update(result.toInventoryId, (draft) => { draft.platinum += result.currency.platinum ?? 0; draft.gold += result.currency.gold ?? 0; draft.electrum += result.currency.electrum ?? 0; draft.silver += result.currency.silver ?? 0; draft.copper += result.currency.copper ?? 0; }); }, } as const;
const doClient = buildSubscriptionClient(inventoryId, handlers as never); unsubscribers.push(doClient.unsubscribe); });
return () => { unsubscribers.forEach((unsubscribe) => unsubscribe()); }; }, [clientsById, buildSubscriptionClient]);
return currencyCollection;}Client: builder-based wiring
Section titled “Client: builder-based wiring”If you want declarative inbound/outbound handlers, you can use
createTanstackCollectionFromClient instead of manually wiring subscriptions.
This example includes inventoryId in the update payload so the inbound handler
knows which record to update.
import { createCollection, localStorageCollectionOptions } from '@tanstack/db';import { createSubscriptionClient, createTanstackCollectionFromClient,} from '@repo/client-side-do';import { useEffect, useMemo } from 'react';
type CurrencyRecord = { inventoryId: string; platinum: number; gold: number; electrum: number; silver: number; copper: number;};
type CurrencyRpc = { updateCurrency: (args: { inventoryId: string; currency: Partial<CurrencyRecord>; }) => Promise<CurrencyRecord>; transferCurrency: (args: { transferId: string; fromInventoryId: string; toInventoryId: string; currency: Partial<CurrencyRecord>; }) => Promise<{ transferId: string; fromInventoryId: string; toInventoryId: string; currency: Partial<CurrencyRecord>; }>;};
let currencyHandlers = { onInsert: async () => {}, onUpdate: async () => {}, onDelete: async () => {},};
const currencyCollection = createCollection<CurrencyRecord>( localStorageCollectionOptions({ storageKey: 'currency-db', getKey: (record) => record.inventoryId, onInsert: (params) => currencyHandlers.onInsert(params), onUpdate: (params) => currencyHandlers.onUpdate(params), onDelete: (params) => currencyHandlers.onDelete(params), }));
const buildWsUrl = (inventoryId: string) => `wss://example.com/api/inventory/${inventoryId}/ws`;
export function useCurrencyCollectionBuilder(inventoryIds: string[]) { const buildSubscriptionClient = useMemo(() => { return ( id: string, handlers: Parameters< ReturnType<typeof createSubscriptionClient<CurrencyRpc>> >[1] ) => createSubscriptionClient<CurrencyRpc>({ baseUrl: buildWsUrl(id) })( id, handlers ); }, []);
const subscriptionsById = useMemo(() => { return new Map( inventoryIds.map((id) => { const subscription = buildSubscriptionClient(id, {} as never); return [id, subscription] as const; }) ); }, [inventoryIds, buildSubscriptionClient]);
useEffect(() => { const builder = createTanstackCollectionFromClient< CurrencyRpc, CurrencyRecord >({ collection: currencyCollection, resolveSubscription: ({ record }) => subscriptionsById.get(record.inventoryId) ?? null, });
const { mutationHandlers, buildSubscriptionHandlers } = builder .method('updateCurrency', { outbound: async ({ client, args, metadata }) => { const meta = metadata as { source?: string } | undefined; if (meta?.source === 'bootstrap') { return; } await client.updateCurrency(...args); }, inbound: async ({ collection, payload }) => { const currency = payload.result; if (!currency) { return; } const record = { inventoryId: payload.args[0].inventoryId, ...currency, }; if (!collection.get(record.inventoryId)) { collection.insert(record); } collection.update(record.inventoryId, (draft) => ({ ...draft, ...record, })); }, }) .method('transferCurrency', { outbound: async ({ client, args, metadata }) => { const meta = metadata as { source?: string } | undefined; if (meta?.source === 'bootstrap') { return; } await client.transferCurrency(...args); }, inbound: async ({ collection, payload }) => { const result = payload.result ?? payload.args[0]; if (!result) { return; }
const fromRecord = collection.get(result.fromInventoryId); const toRecord = collection.get(result.toInventoryId);
if (fromRecord) { collection.update(result.fromInventoryId, (draft) => { draft.platinum -= result.currency.platinum ?? 0; draft.gold -= result.currency.gold ?? 0; draft.electrum -= result.currency.electrum ?? 0; draft.silver -= result.currency.silver ?? 0; draft.copper -= result.currency.copper ?? 0; }); }
if (!toRecord) { collection.insert({ inventoryId: result.toInventoryId, platinum: 0, gold: 0, electrum: 0, silver: 0, copper: 0, }); }
collection.update(result.toInventoryId, (draft) => { draft.platinum += result.currency.platinum ?? 0; draft.gold += result.currency.gold ?? 0; draft.electrum += result.currency.electrum ?? 0; draft.silver += result.currency.silver ?? 0; draft.copper += result.currency.copper ?? 0; }); }, }) .done();
currencyHandlers = { onInsert: mutationHandlers.onInsert ?? (async () => {}), onUpdate: mutationHandlers.onUpdate ?? (async () => {}), onDelete: mutationHandlers.onDelete ?? (async () => {}), };
const unsubscribers: Array<() => void> = []; subscriptionsById.forEach((subscription, inventoryId) => { const handlers = buildSubscriptionHandlers(() => { const current = subscriptionsById.get(inventoryId); if (!current) { throw new Error('Subscription not ready'); } return current; }); const doClient = buildSubscriptionClient(inventoryId, handlers); unsubscribers.push(doClient.unsubscribe); });
return () => { unsubscribers.forEach((unsubscribe) => unsubscribe()); }; }, [subscriptionsById, buildSubscriptionClient]);
return currencyCollection;}Mutation metadata with builder:
collection.update( fromInventoryId, (draft) => { draft.gold -= 10; }, { action: { type: 'transferCurrency', args: [ { transferId, fromInventoryId, toInventoryId, currency: { gold: 10 }, }, ], }, });Server: currency DO RPC
Section titled “Server: currency DO RPC”This DO owns the currency state and emits events whenever updates or transfers complete.
import { DurableObject } from 'cloudflare:workers';import { createRpcServer, RpcFromTarget, type DurableObjectLike,} from '@repo/client-side-do';
type InventoryCurrency = { platinum: number; gold: number; electrum: number; silver: number; copper: number;};
const rpcMethods = ['updateCurrency', 'transferCurrency'] as const;
type CurrencyRpc = Pick< RpcFromTarget<InstanceType<typeof CurrencyDO>, 'fetch'>, (typeof rpcMethods)[number]>;
export class CurrencyDO extends DurableObject { private rpcServer: ReturnType<typeof createRpcServer>; private static readonly currencyKey = 'currency';
constructor(ctx: DurableObjectState, env: Record<string, unknown>) { super(ctx, env); this.rpcServer = createRpcServer( this as unknown as DurableObjectLike, rpcMethods ); }
async getCurrency(): Promise<InventoryCurrency> { const stored = await this.ctx.storage.kv.get<InventoryCurrency>( CurrencyDO.currencyKey ); return { platinum: stored?.platinum ?? 0, gold: stored?.gold ?? 0, electrum: stored?.electrum ?? 0, silver: stored?.silver ?? 0, copper: stored?.copper ?? 0, }; }
async updateCurrency({ currency, }: { currency: Partial<InventoryCurrency>; }): Promise<InventoryCurrency> { const current = await this.getCurrency(); const nextCurrency: InventoryCurrency = { platinum: currency.platinum ?? current.platinum, gold: currency.gold ?? current.gold, electrum: currency.electrum ?? current.electrum, silver: currency.silver ?? current.silver, copper: currency.copper ?? current.copper, }; await this.ctx.storage.kv.put(CurrencyDO.currencyKey, nextCurrency); return nextCurrency; }
async transferCurrency({ transferId, fromInventoryId, toInventoryId, currency, }: { transferId: string; fromInventoryId: string; toInventoryId: string; currency: Partial<InventoryCurrency>; }): Promise<{ transferId: string; fromInventoryId: string; toInventoryId: string; currency: Partial<InventoryCurrency>; }> { // Delegate multi-inventory coordination to the Hoarder DO. const result = await this.env.HOARDER.transferCurrency({ transferId, fromInventoryId, toInventoryId, currency, }); if (result.status !== 'completed') { throw new Error(result.reason ?? 'Transfer failed'); } return { transferId, fromInventoryId, toInventoryId, currency, }; }
async fetch(request: Request): Promise<Response> { return this.rpcServer.fetch(request); }
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { await this.rpcServer.handleMessage(ws, message); }
webSocketClose(ws: WebSocket) { this.rpcServer.handleClose(ws); }
webSocketError(ws: WebSocket, error: unknown) { this.rpcServer.handleError(ws, error); }}How to use it
Section titled “How to use it”- Mutate the collection with metadata like
{ action: { type: 'updateCurrency', payload: { currency: { gold: 5 } } } }. - The DO updates its storage, emits an event, and the hook updates local records.
Collection usage examples
Section titled “Collection usage examples”Get the currency for a specific inventory:
const collection = useCurrencyCollectionHook([inventoryId]);const currency = collection.get(inventoryId);Update currency for a single inventory:
collection.update( inventoryId, (draft) => { draft.gold += 5; }, { action: { type: 'updateCurrency', payload: { currency: { gold: 5 } }, }, });Transfer currency between inventories (optimistically update source only):
const transferId = crypto.randomUUID();
collection.update( fromInventoryId, (draft) => { draft.gold -= 10; }, { action: { type: 'transferCurrency', payload: { transferId, fromInventoryId, toInventoryId, currency: { gold: 10 }, }, }, });Inbound transfer handler (updates both source and target):
transferCurrency: { update: async ({ payload, collection }) => { const result = payload.result ?? payload.args[0]; if (!result) { return; }
const fromRecord = collection.get(result.fromInventoryId); const toRecord = collection.get(result.toInventoryId);
if (fromRecord) { collection.update(result.fromInventoryId, (draft) => { draft.platinum -= result.currency.platinum ?? 0; draft.gold -= result.currency.gold ?? 0; draft.electrum -= result.currency.electrum ?? 0; draft.silver -= result.currency.silver ?? 0; draft.copper -= result.currency.copper ?? 0; }); }
if (!toRecord) { collection.insert({ inventoryId: result.toInventoryId, platinum: 0, gold: 0, electrum: 0, silver: 0, copper: 0, }); }
collection.update(result.toInventoryId, (draft) => { draft.platinum += result.currency.platinum ?? 0; draft.gold += result.currency.gold ?? 0; draft.electrum += result.currency.electrum ?? 0; draft.silver += result.currency.silver ?? 0; draft.copper += result.currency.copper ?? 0; }); },}