
Spacetimedb Best Practices
- 15 installs
- 3 repo stars
- Updated January 18, 2026
- douglance/stdb-skills
Apply prioritized SpacetimeDB best practices for TypeScript server modules and client SDK integration with React.
About
Provides categorized, impact-prioritized best practices for SpacetimeDB TypeScript modules, tables, reducers, subscriptions, and React integration. A developer uses it when writing, reviewing, or refactoring SpacetimeDB code.
- Eight prioritized rule categories by impact
- Covers modules, reducers, subscriptions, and React
Spacetimedb Best Practices by the numbers
- 15 all-time installs (skills.sh)
- Ranked #593 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/douglance/stdb-skills --skill spacetimedb-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 3 |
| Last updated | January 18, 2026 |
| Repository | douglance/stdb-skills ↗ |
What it does
Apply prioritized SpacetimeDB best practices for TypeScript server modules and client SDK integration with React.
Files
SpacetimeDB Best Practices
Comprehensive development guide for SpacetimeDB applications, covering both TypeScript server modules and client SDK integration with React. Contains rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
Package: spacetimedb (v1.4.0+)
When to Apply
Reference these guidelines when:
- Writing SpacetimeDB server modules in TypeScript
- Designing table schemas and indexes
- Implementing reducers for state mutations
- Setting up client subscriptions and queries
- Integrating SpacetimeDB with React applications
- Optimizing real-time sync performance
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Module Design | CRITICAL | module- |
| 2 | Table Schema & Indexing | CRITICAL | table- |
| 3 | Reducer Patterns | HIGH | reducer- |
| 4 | Subscription Optimization | HIGH | subscription- |
| 5 | Client State Management | MEDIUM-HIGH | client- |
| 6 | React Integration | MEDIUM | react- |
| 7 | TypeScript Patterns | MEDIUM | ts- |
| 8 | Real-time Sync | LOW-MEDIUM | sync- |
Quick Reference
Server Module API (TypeScript)
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
// Define tables with the table builder
const Player = table(
{ name: 'player', public: true },
{
identity: t.identity().primaryKey(),
name: t.string(),
score: t.u64().index(),
isOnline: t.bool().index(),
}
);
// Define reducers
spacetimedb.reducer('create_player', { name: t.string() }, (ctx: ReducerContext, { name }) => {
ctx.db.player.insert({
identity: ctx.sender,
name,
score: 0n,
isOnline: true,
});
});
// Lifecycle hooks
spacetimedb.init((ctx: ReducerContext) => { /* module init */ });
spacetimedb.clientConnected((ctx: ReducerContext) => { /* client connected */ });
spacetimedb.clientDisconnected((ctx: ReducerContext) => { /* client disconnected */ });Client SDK API (TypeScript)
import { DbConnection } from './generated';
// Build connection
const conn = DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('my-module')
.onConnect((ctx, identity, token) => {
// Setup subscriptions
conn.subscription(['SELECT * FROM player WHERE isOnline = true']);
})
.onDisconnect((ctx, error) => { /* handle disconnect */ })
.build();
// Call reducers
await conn.reducers.createPlayer('Alice');
// Access tables
const player = conn.db.player.identity.find(identity);React Integration
import { useTable, where, eq } from 'spacetimedb/react';
import { DbConnection, Player } from './generated';
function OnlinePlayers() {
const { rows: players } = useTable<DbConnection, Player>(
'player',
where(eq('isOnline', true))
);
return players.map(p => <div key={p.identity.toHexString()}>{p.name}</div>);
}Rule Categories
1. Module Design (CRITICAL)
module-single-responsibility- One module per domain conceptmodule-lifecycle- Use lifecycle hooks appropriately (init, clientConnected, clientDisconnected)module-error-handling- Handle errors gracefully in module codemodule-type-exports- Export types for client consumption
2. Table Schema & Indexing (CRITICAL)
table-primary-keys- Choose appropriate primary key strategiestable-indexing- Add.index()for frequently queried columnstable-relationships- Model relationships between tables correctlytable-column-types- Use appropriate SpacetimeDB types
3. Reducer Patterns (HIGH)
reducer-atomicity- Keep reducers atomic and focusedreducer-validation- Validate inputs at reducer entryreducer-authorization- Check caller identity for sensitive operationsreducer-batch-operations- Batch related mutations in single reducer
4. Subscription Optimization (HIGH)
subscription-selective- Subscribe only to needed datasubscription-filters- Use subscription filters to reduce data transfersubscription-cleanup- Clean up subscriptions when no longer neededsubscription-batching- Batch subscription setup on client connect
5. Client State Management (MEDIUM-HIGH)
client-connection-lifecycle- Handle connection/reconnection properlyclient-optimistic-updates- Use optimistic updates for responsive UIclient-error-recovery- Handle reducer errors gracefullyclient-identity- Manage identity tokens securely
6. React Integration (MEDIUM)
react-use-subscription- Use subscription hooks correctlyreact-table-hooks- UseuseTable<DbConnection, Type>()for reactive datareact-reducer-hooks- Callconn.reducers.*with proper error handlingreact-connection-status- Display connection status to users
7. TypeScript Patterns (MEDIUM)
ts-generated-types- Use generated types from SpacetimeDB CLIts-strict-mode- Enable strict TypeScript for better type safetyts-discriminated-unions- Use discriminated unions for statets-type-guards- Implement type guards for runtime validation
8. Real-time Sync (LOW-MEDIUM)
sync-conflict-resolution- Handle concurrent modificationssync-offline-support- Design for offline-first when neededsync-debounce-updates- Debounce rapid UI updatessync-presence- Implement user presence efficiently
How to Use
Read individual rule files for detailed explanations and code examples:
rules/module-single-responsibility.md
rules/table-primary-keys.md
rules/_sections.mdEach rule file contains:
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Additional context and references
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
{
"version": "2.0.0",
"organization": "SpacetimeDB",
"date": "January 2026",
"abstract": "Comprehensive development guide for SpacetimeDB applications, designed for AI agents and LLMs. Covers TypeScript server modules using the function-builder pattern (spacetimedb.reducer, table, t.*), client SDK with DbConnection.builder(), and React integration with spacetimedb/react hooks (useTable with generics and where clauses). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific patterns for building real-time, multiplayer applications.",
"package": "spacetimedb",
"packageVersion": "1.4.0+",
"references": [
"https://spacetimedb.com/docs",
"https://github.com/clockworklabs/SpacetimeDB",
"https://spacetimedb.com/docs/modules/typescript/quickstart/",
"https://spacetimedb.com/docs/sdks/typescript/",
"https://spacetimedb.com/docs/sdks/typescript/quickstart"
]
}
SpacetimeDB Best Practices
A structured repository for creating and maintaining SpacetimeDB best practices optimized for agents and LLMs.
Structure
rules/- Individual rule files (one per rule)_sections.md- Section metadata (titles, impacts, descriptions)_template.md- Template for creating new rulesarea-description.md- Individual rule filesmetadata.json- Document metadata (version, organization, abstract)- __
AGENTS.md__ - Compiled output (generated) - __
SKILL.md__ - Skill definition for Claude Code
Getting Started
1. Install dependencies:
cd packages/spacetimedb-best-practices-build
pnpm install2. Build AGENTS.md from rules:
pnpm build3. Validate rule files:
pnpm validateCreating a New Rule
1. Copy rules/_template.md to rules/area-description.md 2. Choose the appropriate area prefix:
module-for Module Design (Section 1)table-for Table Schema & Indexing (Section 2)reducer-for Reducer Patterns (Section 3)subscription-for Subscription Optimization (Section 4)client-for Client State Management (Section 5)react-for React Integration (Section 6)ts-for TypeScript Patterns (Section 7)sync-for Real-time Sync (Section 8)
3. Fill in the frontmatter and content 4. Ensure you have clear examples with explanations 5. Run pnpm build to regenerate AGENTS.md
Rule File Structure
Each rule file should follow this structure:
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description
tags: tag1, tag2, tag3
---
## Rule Title Here
Brief explanation of the rule and why it matters.
**Incorrect (description of what's wrong):**
// Bad code example
**Correct (description of what's right):**
// Good code example
Optional explanatory text after examples.
Reference: [Link](https://example.com)File Naming Convention
- Files starting with
_are special (excluded from build) - Rule files:
area-description.md(e.g.,table-indexing.md) - Section is automatically inferred from filename prefix
- Rules are sorted alphabetically by title within each section
- IDs (e.g., 1.1, 1.2) are auto-generated during build
Impact Levels
CRITICAL- Highest priority, major performance/correctness gainsHIGH- Significant improvementsMEDIUM-HIGH- Moderate-high gainsMEDIUM- Moderate improvementsLOW-MEDIUM- Low-medium gainsLOW- Incremental improvements
Scripts
pnpm build- Compile rules into AGENTS.mdpnpm validate- Validate all rule filespnpm dev- Build and validate
Contributing
When adding or modifying rules:
1. Use the correct filename prefix for your section 2. Follow the _template.md structure 3. Include clear bad/good examples with explanations 4. Add appropriate tags 5. Run pnpm build to regenerate AGENTS.md 6. Rules are automatically sorted by title - no need to manage numbers!
API Reference
This skill targets SpacetimeDB TypeScript SDK v1.4.0+ with the function-builder API:
- Server modules:
spacetimedb.reducer(),table(),t.*type builders - Client SDK:
DbConnection.builder()pattern - React:
spacetimedb/reactwithuseTable<DbConnection, Type>()andwhere()queries
References
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Module Design (module)
Impact: CRITICAL Description: Server module architecture is the foundation of SpacetimeDB applications. Well-designed modules are easier to maintain, test, and scale.
2. Table Schema & Indexing (table)
Impact: CRITICAL Description: Table design directly impacts query performance and data integrity. Proper indexing is essential for efficient subscriptions and queries.
3. Reducer Patterns (reducer)
Impact: HIGH Description: Reducers are the only way to mutate state in SpacetimeDB. Correct reducer design ensures data consistency and proper authorization.
4. Subscription Optimization (subscription)
Impact: HIGH Description: Subscriptions power real-time updates. Efficient subscription patterns minimize bandwidth and improve client performance.
5. Client State Management (client)
Impact: MEDIUM-HIGH Description: Client-side state management handles connection lifecycle, optimistic updates, and error recovery for responsive applications.
6. React Integration (react)
Impact: MEDIUM Description: React hooks and patterns for building reactive UIs with SpacetimeDB data and reducers.
7. TypeScript Patterns (ts)
Impact: MEDIUM Description: TypeScript best practices for type safety and code quality in SpacetimeDB applications.
8. Real-time Sync (sync)
Impact: LOW-MEDIUM Description: Advanced patterns for handling concurrent modifications, offline support, and real-time presence.
Rule Title Here
Impact: MEDIUM (optional impact description)
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
Incorrect (description of what's wrong):
// Bad code example here
const bad = example()Correct (description of what's right):
// Good code example here
const good = example()Reference: Link to documentation or resource
Connection Lifecycle Management
Impact: MEDIUM-HIGH (Ensures reliable real-time sync and user experience)
Properly handle connection, disconnection, and reconnection events on the client. This ensures users see accurate connection status and data syncs correctly after reconnection.
Incorrect (ignoring connection events):
// No connection state handling - using old API
import { DbConnection } from './generated';
// Just connect and hope for the best
const conn = DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('my-module')
.build();
// No handling of disconnects or errors
// User has no idea if they're connected or notCorrect (comprehensive connection lifecycle):
import { DbConnection, Identity } from './generated';
// Connection state management
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
class GameClient {
private conn: DbConnection | null = null;
private connectionState: ConnectionState = 'disconnected';
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
private onStateChange: (state: ConnectionState) => void;
private currentChannelId: string | null = null;
private identity: Identity | null = null;
private token: string | null = null;
constructor(onStateChange: (state: ConnectionState) => void) {
this.onStateChange = onStateChange;
}
async connect() {
this.connectionState = 'connecting';
this.onStateChange('connecting');
try {
this.conn = DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('game-module')
.onConnect((ctx, identity, token) => {
console.log('Connected to SpacetimeDB');
this.identity = identity;
this.token = token;
this.connectionState = 'connected';
this.reconnectAttempts = 0;
this.onStateChange('connected');
// Re-establish subscriptions after connection
this.setupSubscriptions();
})
.onDisconnect((ctx, error) => {
console.log('Disconnected from SpacetimeDB', error);
if (this.connectionState === 'connected') {
// Unexpected disconnect - attempt reconnection
this.connectionState = 'reconnecting';
this.onStateChange('reconnecting');
this.attemptReconnect();
} else {
this.connectionState = 'disconnected';
this.onStateChange('disconnected');
}
})
.onConnectError((ctx, error) => {
console.error('SpacetimeDB connection error:', error);
this.connectionState = 'disconnected';
this.onStateChange('disconnected');
})
.build();
} catch (error) {
console.error('Initial connection failed:', error);
this.connectionState = 'disconnected';
this.onStateChange('disconnected');
throw error;
}
}
private async attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
this.connectionState = 'disconnected';
this.onStateChange('disconnected');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
await new Promise(resolve => setTimeout(resolve, delay));
try {
await this.connect();
} catch (error) {
console.error('Reconnection failed:', error);
this.attemptReconnect();
}
}
private setupSubscriptions() {
if (!this.conn || !this.identity) return;
// Re-subscribe to relevant data after connection
this.conn.subscription(['SELECT * FROM player WHERE isOnline = true']);
if (this.currentChannelId) {
this.conn.subscription([
'SELECT * FROM message WHERE channelId = ?',
this.currentChannelId
]);
}
}
setCurrentChannel(channelId: string) {
this.currentChannelId = channelId;
if (this.conn && this.connectionState === 'connected') {
this.conn.subscription([
'SELECT * FROM message WHERE channelId = ?',
channelId
]);
}
}
disconnect() {
this.connectionState = 'disconnected';
this.conn = null;
this.onStateChange('disconnected');
}
getConnectionState(): ConnectionState {
return this.connectionState;
}
getConnection(): DbConnection | null {
return this.conn;
}
}
export { GameClient, ConnectionState };// React hook for connection state
import { useState, useEffect, useRef } from 'react';
import { GameClient, ConnectionState } from './GameClient';
function useConnectionState() {
const [state, setState] = useState<ConnectionState>('disconnected');
const clientRef = useRef<GameClient | null>(null);
useEffect(() => {
clientRef.current = new GameClient(setState);
clientRef.current.connect();
return () => {
clientRef.current?.disconnect();
};
}, []);
return { state, client: clientRef.current };
}
// Connection status indicator component
function ConnectionStatus() {
const { state } = useConnectionState();
const statusMap = {
disconnected: { color: 'red', text: 'Offline' },
connecting: { color: 'yellow', text: 'Connecting...' },
connected: { color: 'green', text: 'Connected' },
reconnecting: { color: 'orange', text: 'Reconnecting...' }
};
const status = statusMap[state];
return (
<div style={{ color: status.color }}>
{status.text}
</div>
);
}
export { useConnectionState, ConnectionStatus };Reference: SpacetimeDB TypeScript Client SDK
Error Handling in Modules
Impact: CRITICAL (Ensures graceful failure and debugging)
Handle errors gracefully in SpacetimeDB modules by throwing descriptive errors that clients can handle. Avoid silent failures that leave the database in inconsistent states.
Incorrect (poor error handling):
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
spacetimedb.reducer(
'purchase_item',
{ itemId: t.string(), quantity: t.u32() },
(ctx: ReducerContext, { itemId, quantity }) => {
const user = ctx.db.user.identity.find(ctx.sender);
const item = ctx.db.item.id.find(itemId);
// Silent failure - no error if user or item doesn't exist
if (!user || !item) {
return; // Client has no idea what went wrong
}
// No validation - can go negative
ctx.db.user.identity.update({ ...user, balance: user.balance - item.price * BigInt(quantity) });
// Silent failure if insufficient stock
if (item.stock < quantity) {
return; // Purchase "succeeded" but nothing happened
}
ctx.db.item.id.update({ ...item, stock: item.stock - quantity });
}
);Correct (comprehensive error handling):
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
// Custom error types for different failure modes
class InsufficientBalanceError extends Error {
constructor(required: bigint, available: bigint) {
super(`Insufficient balance: need ${required}, have ${available}`);
this.name = 'InsufficientBalanceError';
}
}
class InsufficientStockError extends Error {
constructor(itemName: string, requested: number, available: number) {
super(`Insufficient stock for ${itemName}: requested ${requested}, available ${available}`);
this.name = 'InsufficientStockError';
}
}
class NotFoundError extends Error {
constructor(entity: string, id: string) {
super(`${entity} not found: ${id}`);
this.name = 'NotFoundError';
}
}
function generateId(): string {
return crypto.randomUUID();
}
spacetimedb.reducer(
'purchase_item',
{ itemId: t.string(), quantity: t.u32() },
(ctx: ReducerContext, { itemId, quantity }) => {
// Validate inputs
if (!itemId || itemId.trim().length === 0) {
throw new Error('Item ID is required');
}
if (quantity <= 0) {
throw new Error('Quantity must be a positive integer');
}
// Find user
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) {
throw new NotFoundError('User', ctx.sender.toHexString());
}
// Find item
const item = ctx.db.item.id.find(itemId);
if (!item) {
throw new NotFoundError('Item', itemId);
}
// Check stock availability
if (item.stock < quantity) {
throw new InsufficientStockError(item.name, quantity, item.stock);
}
// Calculate total cost
const totalCost = item.price * BigInt(quantity);
// Check balance
if (user.balance < totalCost) {
throw new InsufficientBalanceError(totalCost, user.balance);
}
// All validations passed - perform the transaction
ctx.db.user.identity.update({
...user,
balance: user.balance - totalCost
});
ctx.db.item.id.update({
...item,
stock: item.stock - quantity
});
// Record the purchase
ctx.db.purchase.insert({
id: generateId(),
userId: ctx.sender.toHexString(),
itemId,
quantity,
totalCost,
timestamp: ctx.timestamp
});
// Log for debugging
console.log(`Purchase completed: ${user.name} bought ${quantity}x ${item.name} for ${totalCost}`);
}
);// Client-side error handling
import { DbConnection } from './generated';
async function handlePurchase(conn: DbConnection, itemId: string, quantity: number) {
try {
await conn.reducers.purchaseItem(itemId, quantity);
showToast('Purchase successful!', 'success');
} catch (error) {
if (error instanceof Error) {
// Handle specific error types
if (error.message.includes('Insufficient balance')) {
showToast('Not enough coins! Earn more or buy coins.', 'warning');
showBuyCoinsModal();
} else if (error.message.includes('Insufficient stock')) {
showToast('Item out of stock. Try again later.', 'error');
} else if (error.message.includes('not found')) {
showToast('Item no longer available.', 'error');
refreshInventory();
} else {
// Generic error
showToast(`Purchase failed: ${error.message}`, 'error');
}
} else {
showToast('An unexpected error occurred.', 'error');
}
console.error('Purchase error:', error);
}
}Reference: SpacetimeDB TypeScript Module Quickstart
Lifecycle Hooks
Impact: CRITICAL (Ensures proper initialization and cleanup)
SpacetimeDB provides lifecycle hooks for module initialization (init), client connection (clientConnected), and disconnection (clientDisconnected). Use these appropriately to set up initial state, track connected users, and clean up resources.
Incorrect (missing lifecycle hooks):
// No initialization or connection tracking
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
const GameState = table(
{ name: 'game_state', public: true },
{
id: t.string().primaryKey(),
status: t.string(),
}
);
const Player = table(
{ name: 'player', public: true },
{
identityId: t.string().primaryKey(),
name: t.string(),
isOnline: t.bool(), // Never updated!
}
);
spacetimedb.reducer('join_game', { name: t.string() }, (ctx: ReducerContext, { name }) => {
// Player online status never gets set properly
ctx.db.player.insert({
identityId: ctx.sender.toHexString(),
name,
isOnline: true
});
});Correct (proper lifecycle management):
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
const GameState = table(
{ name: 'game_state', public: true },
{
id: t.string().primaryKey(),
status: t.string(), // 'waiting' | 'active' | 'finished'
createdAt: t.u64(),
}
);
const Player = table(
{ name: 'player', public: true },
{
identityId: t.identity().primaryKey(),
name: t.string(),
isOnline: t.bool(),
lastSeen: t.u64(),
}
);
// Initialize game state when module is first published
spacetimedb.init((ctx: ReducerContext) => {
// Create initial game state if it doesn't exist
if (!ctx.db.game_state.id.find('main')) {
ctx.db.game_state.insert({
id: 'main',
status: 'waiting',
createdAt: ctx.timestamp
});
}
});
// Track when a client connects
spacetimedb.clientConnected((ctx: ReducerContext) => {
const identityId = ctx.sender;
const player = ctx.db.player.identityId.find(identityId);
if (player) {
// Mark existing player as online
ctx.db.player.identityId.update({
...player,
isOnline: true,
lastSeen: ctx.timestamp
});
}
});
// Track when a client disconnects
spacetimedb.clientDisconnected((ctx: ReducerContext) => {
const identityId = ctx.sender;
const player = ctx.db.player.identityId.find(identityId);
if (player) {
ctx.db.player.identityId.update({
...player,
isOnline: false,
lastSeen: ctx.timestamp
});
}
});
spacetimedb.reducer('register_player', { name: t.string() }, (ctx: ReducerContext, { name }) => {
const identityId = ctx.sender;
if (ctx.db.player.identityId.find(identityId)) {
throw new Error('Player already registered');
}
ctx.db.player.insert({
identityId,
name,
isOnline: true,
lastSeen: ctx.timestamp
});
});Reference: SpacetimeDB TypeScript Module Quickstart
Single Responsibility Modules
Impact: CRITICAL (Improves maintainability and testability)
Each SpacetimeDB module should focus on a single domain concept. This makes modules easier to understand, test, and maintain. Avoid creating "god modules" that handle multiple unrelated concerns.
Incorrect (multiple concerns in one module):
// module.ts - handles users, products, AND orders
import { spacetimedb, table, t, ReducerContext, Table } from 'spacetimedb';
const User = table(
{ name: 'user', public: true },
{
id: t.string().primaryKey(),
name: t.string(),
}
);
const Product = table(
{ name: 'product', public: true },
{
id: t.string().primaryKey(),
name: t.string(),
price: t.u64(),
}
);
const Order = table(
{ name: 'order', public: true },
{
id: t.string().primaryKey(),
userId: t.string(),
productId: t.string(),
}
);
// Too many unrelated reducers in one module
spacetimedb.reducer('create_user', { name: t.string() }, (ctx: ReducerContext, { name }) => {
/* ... */
});
spacetimedb.reducer('update_product', { id: t.string(), price: t.u64() }, (ctx: ReducerContext, { id, price }) => {
/* ... */
});
spacetimedb.reducer('place_order', { userId: t.string(), productId: t.string() }, (ctx: ReducerContext, { userId, productId }) => {
/* ... */
});Correct (separate modules per domain):
// users/module.ts
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
export const User = table(
{ name: 'user', public: true },
{
id: t.string().primaryKey(),
name: t.string(),
email: t.string(),
}
);
function generateId(): string {
return crypto.randomUUID();
}
spacetimedb.reducer('create_user', { name: t.string(), email: t.string() }, (ctx: ReducerContext, { name, email }) => {
ctx.db.user.insert({ id: generateId(), name, email });
});
spacetimedb.reducer('update_user', { id: t.string(), name: t.string() }, (ctx: ReducerContext, { id, name }) => {
const user = ctx.db.user.id.find(id);
if (user) {
ctx.db.user.id.update({ ...user, name });
}
});// orders/module.ts
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
export const Order = table(
{ name: 'order', public: true },
{
id: t.string().primaryKey(),
userId: t.string().index(),
productId: t.string(),
quantity: t.u32(),
createdAt: t.u64(),
}
);
function generateId(): string {
return crypto.randomUUID();
}
spacetimedb.reducer('place_order', { productId: t.string(), quantity: t.u32() }, (ctx: ReducerContext, { productId, quantity }) => {
const userId = ctx.sender.toHexString();
ctx.db.order.insert({
id: generateId(),
userId,
productId,
quantity,
createdAt: ctx.timestamp
});
});Reference: SpacetimeDB TypeScript Module Quickstart
Reducer Hooks with Error Handling
Impact: MEDIUM (Provides consistent reducer invocation with proper error handling)
Use hooks to call SpacetimeDB reducers with proper loading states, error handling, and optimistic updates. This improves user experience with immediate feedback.
Incorrect (no error handling or loading states):
import { useState } from 'react';
import { DbConnection } from './generated';
function SendMessageButton({ channelId, conn }: { channelId: string; conn: DbConnection }) {
const [message, setMessage] = useState('');
const handleSend = () => {
// Fire and forget - no feedback to user
conn.reducers.sendMessage(channelId, message);
setMessage('');
};
return (
<div>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSend}>Send</button>
</div>
);
}Correct (proper hooks with error handling):
import { useState, useCallback } from 'react';
import { DbConnection } from './generated';
// Custom hook for reducer with loading/error states
function useReducerWithState<T extends any[]>(
conn: DbConnection | null,
reducerName: keyof DbConnection['reducers']
): [(...args: T) => Promise<void>, { loading: boolean; error: Error | null }] {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const call = useCallback(async (...args: T) => {
if (!conn) {
setError(new Error('Not connected'));
return;
}
setLoading(true);
setError(null);
try {
await (conn.reducers[reducerName] as (...args: T) => Promise<void>)(...args);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
throw error;
} finally {
setLoading(false);
}
}, [conn, reducerName]);
return [call, { loading, error }];
}
// Message sending with proper feedback
function MessageInput({ channelId, conn }: { channelId: string; conn: DbConnection }) {
const [message, setMessage] = useState('');
const [sendMessage, { loading, error }] = useReducerWithState<[string, string]>(
conn,
'sendMessage'
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
try {
await sendMessage(channelId, message.trim());
setMessage(''); // Only clear on success
} catch (err) {
// Error is already set in hook state
console.error('Failed to send message:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={message}
onChange={e => setMessage(e.target.value)}
disabled={loading}
placeholder="Type a message..."
/>
<button type="submit" disabled={loading || !message.trim()}>
{loading ? 'Sending...' : 'Send'}
</button>
{error && (
<div style={{ color: 'red' }}>
Failed to send: {error.message}
</div>
)}
</form>
);
}
// Optimistic updates for instant feedback
function LikeButton({
postId,
currentLikes,
conn
}: {
postId: string;
currentLikes: number;
conn: DbConnection;
}) {
const [optimisticLikes, setOptimisticLikes] = useState<number | null>(null);
const [likePost, { loading, error }] = useReducerWithState<[string]>(
conn,
'likePost'
);
const handleLike = async () => {
// Optimistic update - show new count immediately
setOptimisticLikes(currentLikes + 1);
try {
await likePost(postId);
// Success - optimistic update will be replaced by real data
} catch (err) {
// Revert optimistic update on failure
setOptimisticLikes(null);
}
};
const displayLikes = optimisticLikes ?? currentLikes;
return (
<button onClick={handleLike} disabled={loading}>
❤️ {displayLikes}
{error && ' (failed)'}
</button>
);
}
// Complex form with multiple reducers
function CreateGameForm({ conn }: { conn: DbConnection }) {
const [gameName, setGameName] = useState('');
const [maxPlayers, setMaxPlayers] = useState(4);
const [createGame, createState] = useReducerWithState<[string, number]>(
conn,
'createGame'
);
const [joinGame, joinState] = useReducerWithState<[string]>(
conn,
'joinGame'
);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Create game and join it
await createGame(gameName, maxPlayers);
// After game is created, we'd typically get the game ID from state
// and then join it
} catch (err) {
// Error states are handled by individual hooks
}
};
const loading = createState.loading || joinState.loading;
const error = createState.error || joinState.error;
return (
<form onSubmit={handleCreate}>
<input
value={gameName}
onChange={e => setGameName(e.target.value)}
placeholder="Game name"
disabled={loading}
/>
<select
value={maxPlayers}
onChange={e => setMaxPlayers(Number(e.target.value))}
disabled={loading}
>
{[2, 4, 6, 8].map(n => (
<option key={n} value={n}>{n} players</option>
))}
</select>
<button type="submit" disabled={loading || !gameName.trim()}>
{loading ? 'Creating...' : 'Create Game'}
</button>
{error && (
<div style={{ color: 'red' }}>
{error.message}
</div>
)}
</form>
);
}Reference: SpacetimeDB TypeScript Client SDK
React Table Hooks
Impact: MEDIUM (Simplifies reactive data binding in React)
Use SpacetimeDB's React hooks to automatically re-render components when table data changes. This provides a reactive data binding experience similar to other state management libraries.
Incorrect (manual state management):
import { useState, useEffect } from 'react';
import { DbConnection, Player } from './generated';
// Manually managing state - error-prone and verbose
function PlayerList({ conn }: { conn: DbConnection }) {
const [players, setPlayers] = useState<Player[]>([]);
useEffect(() => {
// Manual subscription setup
const sub = conn.subscription(['SELECT * FROM player WHERE isOnline = true']);
// Manual event handling
const handleInsert = (player: Player) => {
setPlayers(prev => [...prev, player]);
};
const handleUpdate = (oldPlayer: Player, newPlayer: Player) => {
setPlayers(prev => prev.map(p =>
p.identity.toHexString() === newPlayer.identity.toHexString() ? newPlayer : p
));
};
const handleDelete = (player: Player) => {
setPlayers(prev => prev.filter(p =>
p.identity.toHexString() !== player.identity.toHexString()
));
};
conn.db.player.onInsert(handleInsert);
conn.db.player.onUpdate(handleUpdate);
conn.db.player.onDelete(handleDelete);
return () => {
sub?.unsubscribe();
conn.db.player.offInsert(handleInsert);
conn.db.player.offUpdate(handleUpdate);
conn.db.player.offDelete(handleDelete);
};
}, [conn]);
return (
<ul>
{players.map(player => (
<li key={player.identity.toHexString()}>{player.name}</li>
))}
</ul>
);
}Correct (using React hooks):
import { useMemo } from 'react';
import { useTable, where, eq } from 'spacetimedb/react';
import { DbConnection, Player, Message } from './generated';
// Simple table hook - all rows
function PlayerList() {
// Automatically re-renders when player table changes
const { rows: players } = useTable<DbConnection, Player>('player');
return (
<ul>
{players.map(player => (
<li key={player.identity.toHexString()}>
{player.name} - {player.score} points
</li>
))}
</ul>
);
}
// Filtered query hook using where clause
function OnlinePlayerList() {
// Only re-renders when online players change
const { rows: onlinePlayers } = useTable<DbConnection, Player>(
'player',
where(eq('isOnline', true))
);
return (
<ul>
{onlinePlayers.map(player => (
<li key={player.identity.toHexString()}>
{player.name} - Online
</li>
))}
</ul>
);
}
// Single row lookup
function PlayerProfile({ playerId }: { playerId: string }) {
const { rows: players } = useTable<DbConnection, Player>('player');
// Find the specific player
const player = players.find(p => p.identity.toHexString() === playerId);
if (!player) {
return <div>Player not found</div>;
}
return (
<div>
<h2>{player.name}</h2>
<p>Score: {player.score}</p>
<p>Status: {player.isOnline ? 'Online' : 'Offline'}</p>
</div>
);
}
// Computed/derived data
function GameStats() {
const { rows: players } = useTable<DbConnection, Player>('player');
// Derived calculations are memoized
const stats = useMemo(() => ({
totalPlayers: players.length,
onlinePlayers: players.filter(p => p.isOnline).length,
averageScore: players.length > 0
? players.reduce((sum, p) => sum + Number(p.score), 0) / players.length
: 0,
topPlayer: players.reduce((top, p) =>
Number(p.score) > (top ? Number(top.score) : 0) ? p : top,
null as Player | null
)
}), [players]);
return (
<div>
<p>Total Players: {stats.totalPlayers}</p>
<p>Online: {stats.onlinePlayers}</p>
<p>Average Score: {stats.averageScore.toFixed(0)}</p>
{stats.topPlayer && (
<p>Top Player: {stats.topPlayer.name} ({stats.topPlayer.score.toString()})</p>
)}
</div>
);
}
// Multiple tables with relationships
function MessageWithAuthor({ messageId }: { messageId: string }) {
const { rows: messages } = useTable<DbConnection, Message>('message');
const { rows: players } = useTable<DbConnection, Player>('player');
const message = messages.find(m => m.id === messageId);
const author = message
? players.find(p => p.identity.toHexString() === message.authorId.toHexString())
: null;
if (!message || !author) {
return <div>Loading...</div>;
}
return (
<div>
<strong>{author.name}</strong>: {message.content}
</div>
);
}Reference: SpacetimeDB TypeScript Client SDK
Atomic Reducers
Impact: HIGH (Ensures data consistency and predictable behavior)
Reducers in SpacetimeDB are transactional - they either complete entirely or roll back. Design reducers to be atomic: each reducer should perform one logical operation. This ensures data consistency and makes debugging easier.
Incorrect (non-atomic reducer doing too much):
import { spacetimedb, t, ReducerContext } from 'spacetimedb';
spacetimedb.reducer(
'process_game_turn',
{ playerId: t.string(), action: t.string() },
(ctx: ReducerContext, { playerId, action }) => {
// This reducer does too many things - if it fails partway through,
// it's hard to know what state we're in
// Step 1: Update player position
const player = ctx.db.player.id.find(playerId);
ctx.db.player.id.update({ ...player, position: calculateNewPosition(action) });
// Step 2: Check for collisions with all other players
const allPlayers = ctx.db.player.iter();
for (const other of allPlayers) {
if (checkCollision(player, other)) {
// Step 3: Apply damage
ctx.db.player.id.update({ ...other, health: other.health - 10 });
// Step 4: Create combat log
ctx.db.combat_log.insert({ /* ... */ });
// Step 5: Update leaderboard
ctx.db.leaderboard.id.update({ /* ... */ });
// Step 6: Award achievements
checkAndAwardAchievements(ctx, playerId);
}
}
// Step 7: Advance game state
advanceGameState(ctx);
}
);Correct (atomic, focused reducers):
import { spacetimedb, t, ReducerContext } from 'spacetimedb';
function generateId(): string {
return crypto.randomUUID();
}
// Each reducer does ONE thing well
spacetimedb.reducer(
'move_player',
{ direction: t.string() },
(ctx: ReducerContext, { direction }) => {
const playerId = ctx.sender;
const player = ctx.db.player.identity.find(playerId);
if (!player) {
throw new Error('Player not found');
}
const newPosition = calculateNewPosition(player.position, direction);
if (!isValidPosition(newPosition)) {
throw new Error('Invalid move');
}
ctx.db.player.identity.update({
...player,
position: newPosition,
lastMoveAt: ctx.timestamp
});
}
);
spacetimedb.reducer(
'attack_player',
{ targetId: t.identity(), damage: t.u32() },
(ctx: ReducerContext, { targetId, damage }) => {
const attackerId = ctx.sender;
const target = ctx.db.player.identity.find(targetId);
if (!target) {
throw new Error('Target not found');
}
const newHealth = Math.max(0, target.health - damage);
ctx.db.player.identity.update({
...target,
health: newHealth
});
ctx.db.combat_log.insert({
id: generateId(),
attackerId,
targetId,
damage,
timestamp: ctx.timestamp
});
}
);
spacetimedb.reducer(
'claim_achievement',
{ achievementId: t.string() },
(ctx: ReducerContext, { achievementId }) => {
const playerId = ctx.sender;
const player = ctx.db.player.identity.find(playerId);
if (!player) {
throw new Error('Player not found');
}
// Verify achievement requirements
if (!checkAchievementRequirements(ctx, playerId, achievementId)) {
throw new Error('Achievement requirements not met');
}
// Check if already claimed
const existing = ctx.db.player_achievement.playerId.filter(playerId)
.find(a => a.achievementId === achievementId);
if (existing) {
throw new Error('Achievement already claimed');
}
ctx.db.player_achievement.insert({
playerId,
achievementId,
claimedAt: ctx.timestamp
});
}
);Reference: SpacetimeDB TypeScript Module Quickstart
Authorization Checks
Impact: HIGH (Prevents unauthorized access and data manipulation)
Always verify the caller's identity and permissions before performing sensitive operations. Use ctx.sender to identify the caller and check against stored permissions or ownership.
Incorrect (no authorization checks):
import { spacetimedb, t, ReducerContext } from 'spacetimedb';
spacetimedb.reducer(
'delete_message',
{ messageId: t.string() },
(ctx: ReducerContext, { messageId }) => {
// Anyone can delete any message!
ctx.db.message.id.delete(messageId);
}
);
spacetimedb.reducer(
'update_user_profile',
{ userId: t.identity(), newName: t.string() },
(ctx: ReducerContext, { userId, newName }) => {
// Anyone can update any user's profile!
const user = ctx.db.user.identity.find(userId);
ctx.db.user.identity.update({ ...user, name: newName });
}
);
spacetimedb.reducer(
'ban_user',
{ userId: t.identity() },
(ctx: ReducerContext, { userId }) => {
// Anyone can ban anyone!
const user = ctx.db.user.identity.find(userId);
ctx.db.user.identity.update({ ...user, isBanned: true });
}
);Correct (proper authorization):
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
const Admin = table(
{ name: 'admin', public: true },
{
identity: t.identity().primaryKey(),
role: t.string(), // 'super_admin' | 'moderator'
}
);
function generateId(): string {
return crypto.randomUUID();
}
spacetimedb.reducer(
'delete_message',
{ messageId: t.string() },
(ctx: ReducerContext, { messageId }) => {
const callerId = ctx.sender;
const message = ctx.db.message.id.find(messageId);
if (!message) {
throw new Error('Message not found');
}
// Check if caller is the message author OR an admin
const isAuthor = message.authorId.toHexString() === callerId.toHexString();
const isAdmin = ctx.db.admin.identity.find(callerId) !== undefined;
if (!isAuthor && !isAdmin) {
throw new Error('Not authorized to delete this message');
}
ctx.db.message.id.delete(messageId);
// Log admin actions for audit
if (isAdmin && !isAuthor) {
ctx.db.audit_log.insert({
id: generateId(),
adminId: callerId,
action: 'delete_message',
targetId: messageId,
timestamp: ctx.timestamp
});
}
}
);
spacetimedb.reducer(
'update_user_profile',
{ newName: t.string() },
(ctx: ReducerContext, { newName }) => {
// Users can only update their OWN profile
const userId = ctx.sender;
const user = ctx.db.user.identity.find(userId);
if (!user) {
throw new Error('User not found');
}
if (!newName || newName.trim().length === 0) {
throw new Error('Name cannot be empty');
}
ctx.db.user.identity.update({
...user,
name: newName.trim(),
updatedAt: ctx.timestamp
});
}
);
spacetimedb.reducer(
'ban_user',
{ userId: t.identity(), reason: t.string() },
(ctx: ReducerContext, { userId, reason }) => {
const adminId = ctx.sender;
// Verify caller is an admin
const admin = ctx.db.admin.identity.find(adminId);
if (!admin) {
throw new Error('Only admins can ban users');
}
// Verify target exists
const targetUser = ctx.db.user.identity.find(userId);
if (!targetUser) {
throw new Error('User not found');
}
// Prevent banning other admins (unless super_admin)
const targetAdmin = ctx.db.admin.identity.find(userId);
if (targetAdmin && admin.role !== 'super_admin') {
throw new Error('Only super admins can ban other admins');
}
// Prevent self-ban
if (adminId.toHexString() === userId.toHexString()) {
throw new Error('Cannot ban yourself');
}
ctx.db.user.identity.update({
...targetUser,
isBanned: true,
bannedAt: ctx.timestamp,
bannedBy: adminId,
banReason: reason
});
ctx.db.audit_log.insert({
id: generateId(),
adminId,
action: 'ban_user',
targetId: userId.toHexString(),
details: reason,
timestamp: ctx.timestamp
});
}
);Reference: SpacetimeDB TypeScript Module Quickstart
Input Validation at Reducer Entry
Impact: HIGH (Prevents invalid state and security issues)
Always validate all inputs at the start of a reducer before performing any database operations. This ensures data integrity and prevents malicious or malformed data from corrupting your database.
Incorrect (no input validation):
import { spacetimedb, t, ReducerContext } from 'spacetimedb';
spacetimedb.reducer(
'create_product',
{ name: t.string(), price: t.u64(), stock: t.u32() },
(ctx: ReducerContext, { name, price, stock }) => {
// No validation - accepts any input!
ctx.db.product.insert({
id: generateId(),
name,
price,
stock,
createdBy: ctx.sender
});
}
);
spacetimedb.reducer(
'transfer_funds',
{ toUserId: t.identity(), amount: t.u64() },
(ctx: ReducerContext, { toUserId, amount }) => {
const fromUser = ctx.db.user.identity.find(ctx.sender);
const toUser = ctx.db.user.identity.find(toUserId);
// No validation - could transfer negative amounts or overdraw!
ctx.db.user.identity.update({ ...fromUser, balance: fromUser.balance - amount });
ctx.db.user.identity.update({ ...toUser, balance: toUser.balance + amount });
}
);Correct (comprehensive input validation):
import { spacetimedb, t, ReducerContext } from 'spacetimedb';
function generateId(): string {
return crypto.randomUUID();
}
spacetimedb.reducer(
'create_product',
{ name: t.string(), price: t.u64(), stock: t.u32() },
(ctx: ReducerContext, { name, price, stock }) => {
// Validate all inputs first
if (!name || name.trim().length === 0) {
throw new Error('Product name is required');
}
if (name.length > 100) {
throw new Error('Product name must be 100 characters or less');
}
if (price <= 0n) {
throw new Error('Price must be positive');
}
// Check for duplicate names
const existing = [...ctx.db.product.iter()].find(p => p.name === name.trim());
if (existing) {
throw new Error('Product with this name already exists');
}
// All validation passed - safe to insert
ctx.db.product.insert({
id: generateId(),
name: name.trim(),
price,
stock,
createdBy: ctx.sender,
createdAt: ctx.timestamp
});
}
);
spacetimedb.reducer(
'transfer_funds',
{ toUserId: t.identity(), amount: t.u64() },
(ctx: ReducerContext, { toUserId, amount }) => {
// Validate amount
if (amount <= 0n) {
throw new Error('Amount must be positive');
}
// Validate recipient exists
const toUser = ctx.db.user.identity.find(toUserId);
if (!toUser) {
throw new Error('Recipient not found');
}
// Validate sender exists and has sufficient balance
const fromUser = ctx.db.user.identity.find(ctx.sender);
if (!fromUser) {
throw new Error('Sender not found');
}
// Prevent self-transfer
if (ctx.sender.toHexString() === toUserId.toHexString()) {
throw new Error('Cannot transfer to yourself');
}
// Check sufficient balance
if (fromUser.balance < amount) {
throw new Error('Insufficient balance');
}
// All validation passed - safe to transfer
ctx.db.user.identity.update({
...fromUser,
balance: fromUser.balance - amount
});
ctx.db.user.identity.update({
...toUser,
balance: toUser.balance + amount
});
// Log the transaction
ctx.db.transaction.insert({
id: generateId(),
fromUserId: ctx.sender,
toUserId,
amount,
timestamp: ctx.timestamp
});
}
);Reference: SpacetimeDB TypeScript Module Quickstart
Selective Subscriptions
Impact: HIGH (Reduces bandwidth and client memory usage by 50-90%)
Subscribe only to the data your client actually needs. Avoid subscribing to entire tables when you only need a subset. Use WHERE clauses to filter server-side.
Incorrect (subscribing to entire tables):
// Client subscribes to ALL data - wasteful!
import { DbConnection } from './generated';
const conn = DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('game-module')
.onConnect((ctx, identity, token) => {
// Downloads ALL messages ever sent
conn.subscription(['SELECT * FROM message']);
// Downloads ALL players, not just ones in current game
conn.subscription(['SELECT * FROM player']);
// Downloads entire inventory for ALL users
conn.subscription(['SELECT * FROM inventory']);
})
.build();Correct (selective subscriptions with filters):
import { DbConnection } from './generated';
let currentChannelId: string;
const conn = DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('game-module')
.onConnect((ctx, identity, token) => {
const myIdentity = identity.toHexString();
// Subscribe only to current user's inventory
conn.subscription([
'SELECT * FROM inventory WHERE ownerId = ?',
myIdentity
]);
// Subscribe only to online players
conn.subscription([
'SELECT * FROM player WHERE isOnline = true'
]);
// Subscribe to leaderboard (top 100 only)
conn.subscription([
'SELECT * FROM player ORDER BY score DESC LIMIT 100'
]);
})
.build();
// Subscribe only to messages in current channel
function subscribeToChannel(channelId: string) {
currentChannelId = channelId;
return conn.subscription([
'SELECT * FROM message WHERE channelId = ?',
channelId
]);
}
// Subscribe only to recent messages (last 24 hours)
function subscribeToRecentMessages(channelId: string) {
const oneDayAgo = BigInt(Date.now() - 24 * 60 * 60 * 1000);
return conn.subscription([
'SELECT * FROM message WHERE channelId = ? AND timestamp > ?',
channelId,
oneDayAgo
]);
}
// Subscribe only to players in the current game room
function subscribeToGameRoom(roomId: string) {
return conn.subscription([
'SELECT * FROM player WHERE roomId = ?',
roomId
]);
}// React component with selective subscription
import { useEffect, useState } from 'react';
import { useTable, where, eq } from 'spacetimedb/react';
import { DbConnection, Message } from './generated';
function ChatRoom({ channelId, conn }: { channelId: string; conn: DbConnection }) {
const [subscription, setSubscription] = useState<any>(null);
useEffect(() => {
// Subscribe when component mounts with specific channel
const sub = conn.subscription([
'SELECT * FROM message WHERE channelId = ?',
channelId
]);
setSubscription(sub);
return () => {
// Unsubscribe when component unmounts or channelId changes
sub?.unsubscribe();
};
}, [channelId, conn]);
// Use React hooks with filtering
const { rows: messages } = useTable<DbConnection, Message>(
'message',
where(eq('channelId', channelId))
);
return (
<div>
{messages.map(msg => (
<MessageComponent key={msg.id} message={msg} />
))}
</div>
);
}Reference: SpacetimeDB TypeScript Client SDK
Debounce Rapid Updates
Impact: LOW-MEDIUM (Reduces unnecessary re-renders and network traffic)
When handling real-time updates that can arrive rapidly (like cursor positions, typing indicators, or frequent state changes), debounce the UI updates to prevent excessive re-renders and improve perceived performance.
Incorrect (updating on every change):
import { useTable } from 'spacetimedb/react';
import { DbConnection, PlayerCursor } from './generated';
function CursorOverlay() {
const { rows: cursors } = useTable<DbConnection, PlayerCursor>('player_cursor');
// Re-renders on EVERY cursor movement - could be 60+ times per second per player!
return (
<div>
{cursors.map(cursor => (
<div
key={cursor.playerId.toHexString()}
style={{
position: 'absolute',
left: cursor.x,
top: cursor.y
}}
>
{cursor.playerName}
</div>
))}
</div>
);
}
// Sending every mouse move - floods the server
function Canvas({ conn }: { conn: DbConnection }) {
const handleMouseMove = (e: React.MouseEvent) => {
conn.reducers.updateCursor(e.clientX, e.clientY);
};
return <canvas onMouseMove={handleMouseMove} />;
}Correct (debounced/throttled updates):
import { useMemo, useRef, useEffect, useCallback, useState } from 'react';
import { useTable } from 'spacetimedb/react';
import { DbConnection, PlayerCursor, TypingIndicator } from './generated';
// Throttle hook for limiting update frequency
function useThrottle<T>(value: T, interval: number): T {
const [throttledValue, setThrottledValue] = useState(value);
const lastUpdated = useRef(Date.now());
useEffect(() => {
const now = Date.now();
if (now - lastUpdated.current >= interval) {
lastUpdated.current = now;
setThrottledValue(value);
} else {
const timerId = setTimeout(() => {
lastUpdated.current = Date.now();
setThrottledValue(value);
}, interval - (now - lastUpdated.current));
return () => clearTimeout(timerId);
}
}, [value, interval]);
return throttledValue;
}
// Throttled cursor overlay - updates at most 30fps
function CursorOverlay() {
const { rows: rawCursors } = useTable<DbConnection, PlayerCursor>('player_cursor');
// Throttle to 30fps for smooth but performant rendering
const cursors = useThrottle(rawCursors, 33);
// Use CSS transforms for GPU acceleration
return (
<div>
{cursors.map(cursor => (
<div
key={cursor.playerId.toHexString()}
style={{
position: 'absolute',
transform: `translate(${cursor.x}px, ${cursor.y}px)`,
willChange: 'transform'
}}
>
{cursor.playerName}
</div>
))}
</div>
);
}
// Throttled mouse position sending
function Canvas({ conn }: { conn: DbConnection }) {
const lastSent = useRef(0);
const pendingPosition = useRef<{ x: number; y: number } | null>(null);
const sendPosition = useCallback(() => {
if (pendingPosition.current) {
conn.reducers.updateCursor(
pendingPosition.current.x,
pendingPosition.current.y
);
pendingPosition.current = null;
}
}, [conn]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
pendingPosition.current = { x: e.clientX, y: e.clientY };
const now = Date.now();
if (now - lastSent.current >= 50) { // Max 20 updates per second
lastSent.current = now;
sendPosition();
}
}, [sendPosition]);
// Send final position on mouse stop
useEffect(() => {
const interval = setInterval(() => {
if (pendingPosition.current) {
sendPosition();
}
}, 100);
return () => clearInterval(interval);
}, [sendPosition]);
return <canvas onMouseMove={handleMouseMove} />;
}
// Debounced typing indicator
function TypingIndicatorDisplay({ channelId }: { channelId: string }) {
const { rows: typingUsers } = useTable<DbConnection, TypingIndicator>('typing_indicator');
// Filter to current channel and recent activity
const activeTyping = typingUsers.filter(
u => u.channelId === channelId && Number(u.lastTypedAt) > Date.now() - 3000
);
// Debounce the display to avoid flicker
const [debouncedTyping, setDebouncedTyping] = useState(activeTyping);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedTyping(activeTyping);
}, 300);
return () => clearTimeout(timer);
}, [activeTyping]);
if (debouncedTyping.length === 0) return null;
const names = debouncedTyping.map(u => u.userName).join(', ');
return (
<div className="typing-indicator">
{names} {debouncedTyping.length === 1 ? 'is' : 'are'} typing...
</div>
);
}
// Debounced input for sending typing indicator
function MessageInput({ channelId, conn }: { channelId: string; conn: DbConnection }) {
const [message, setMessage] = useState('');
const typingTimeout = useRef<NodeJS.Timeout | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setMessage(e.target.value);
// Debounce typing indicator updates
if (typingTimeout.current) {
clearTimeout(typingTimeout.current);
}
// Send typing indicator
conn.reducers.setTyping(channelId, true);
// Clear typing after 2 seconds of no input
typingTimeout.current = setTimeout(() => {
conn.reducers.setTyping(channelId, false);
}, 2000);
};
return (
<input
value={message}
onChange={handleChange}
placeholder="Type a message..."
/>
);
}Reference: SpacetimeDB TypeScript Client SDK
Index Frequently Queried Columns
Impact: CRITICAL (10-100x faster queries on indexed columns)
Add .index() to columns that are frequently used in subscription filters or lookups. Without indexes, SpacetimeDB must scan the entire table for each query.
Incorrect (missing indexes on frequently queried columns):
import { spacetimedb, table, t } from 'spacetimedb';
const Message = table(
{ name: 'message', public: true },
{
id: t.string().primaryKey(),
// These are frequently queried but not indexed!
senderId: t.identity(),
recipientId: t.identity(),
channelId: t.string(),
content: t.string(),
timestamp: t.u64(),
}
);
// Client subscribes to messages by channel - slow without index!
// subscription: SELECT * FROM message WHERE channelId = 'general'Correct (indexes on query columns):
import { spacetimedb, table, t } from 'spacetimedb';
const Message = table(
{ name: 'message', public: true },
{
id: t.string().primaryKey(),
senderId: t.identity().index(), // Indexed - fast lookups by sender
recipientId: t.identity().index(), // Indexed - fast lookups by recipient
channelId: t.string().index(), // Indexed - fast channel filtering for subscriptions
content: t.string(),
timestamp: t.u64().index(), // Indexed - enables efficient time-range queries
}
);
const Player = table(
{ name: 'player', public: true },
{
identity: t.identity().primaryKey(),
username: t.string().index(), // Indexed - allows finding players by username
score: t.u64().index(), // Indexed - enables leaderboard queries
isOnline: t.bool().index(), // Indexed - fast online player filtering
}
);// Client can now efficiently subscribe to specific data
import { DbConnection } from './generated';
const conn = DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('my-module')
.onConnect((ctx, identity, token) => {
// Fast - uses channelId index
conn.subscription(['SELECT * FROM message WHERE channelId = ?', channelId]);
// Fast - uses isOnline index
conn.subscription(['SELECT * FROM player WHERE isOnline = true']);
// Fast - uses score index for leaderboard
conn.subscription(['SELECT * FROM player ORDER BY score DESC LIMIT 100']);
})
.build();Index Guidelines:
- Index columns used in WHERE clauses
- Index columns used in ORDER BY clauses
- Index foreign key columns for joins
- Don't index columns that are rarely queried
- Don't index columns with very low cardinality (e.g., boolean with 50/50 distribution)
Reference: SpacetimeDB TypeScript Module Quickstart
Primary Key Strategies
Impact: CRITICAL (Affects query performance and data integrity)
Every SpacetimeDB table requires a primary key. Choose the right strategy based on your use case: auto-increment for simple cases, identity-based for user data, or composite keys for relationship tables.
Incorrect (poor primary key choices):
import { spacetimedb, table, t } from 'spacetimedb';
// Using mutable data as primary key
const Player = table(
{ name: 'player', public: true },
{
username: t.string().primaryKey(), // Bad: username might change!
score: t.u64(),
}
);
// No primary key at all
const Message = table(
{ name: 'message', public: true },
{
content: t.string(),
senderId: t.string(),
timestamp: t.u64(),
// Missing .primaryKey() - compilation error
}
);
// Using auto-increment when identity would be better
const UserProfile = table(
{ name: 'user_profile', public: true },
{
id: t.u64().primaryKey().autoInc(), // Bad: loses relationship to identity
displayName: t.string(),
}
);Correct (appropriate primary key strategies):
import { spacetimedb, table, t, ReducerContext } from 'spacetimedb';
// Use identity as primary key for user-owned data
const Player = table(
{ name: 'player', public: true },
{
identity: t.identity().primaryKey(), // Identity is immutable and unique per user
username: t.string(),
score: t.u64(),
}
);
// Use generated UUIDs for entity tables
const Message = table(
{ name: 'message', public: true },
{
id: t.string().primaryKey(), // UUID generated at insert time
senderId: t.identity(),
recipientId: t.identity(),
content: t.string(),
timestamp: t.u64(),
}
);
// Use auto-increment for sequential IDs when appropriate
const GameRound = table(
{ name: 'game_round', public: true },
{
roundNumber: t.u64().primaryKey().autoInc(), // Auto-increment makes sense for sequential data
startedAt: t.u64(),
endedAt: t.u64().optional(),
}
);
// Use composite keys for junction/relationship tables
const Friendship = table(
{ name: 'friendship', public: true },
{
userId: t.identity().primaryKey(),
friendId: t.identity().primaryKey().index(),
createdAt: t.u64(),
}
);// Helper for generating UUIDs
function generateId(): string {
return crypto.randomUUID();
}
spacetimedb.reducer(
'send_message',
{ recipientId: t.identity(), content: t.string() },
(ctx: ReducerContext, { recipientId, content }) => {
ctx.db.message.insert({
id: generateId(),
senderId: ctx.sender,
recipientId,
content,
timestamp: ctx.timestamp
});
}
);Reference: SpacetimeDB TypeScript Module Quickstart
Use Generated Types
Impact: MEDIUM (Ensures type safety between server and client)
Always use the types generated by the SpacetimeDB CLI rather than manually defining them. This ensures your client types exactly match your server schema and prevents runtime type mismatches.
Incorrect (manually defined types):
// Manually defined types - can drift from server schema!
interface Player {
id: string; // Wrong! Server uses identity
name: string;
score: number;
isOnline: boolean;
// Missing lastSeen field that server has!
}
interface Message {
id: string;
authorId: string;
content: string;
timestamp: number; // Wrong! Server uses bigint (u64)
}
function PlayerCard({ player }: { player: Player }) {
// Type mismatch - runtime errors waiting to happen
return <div>{player.name}: {player.score}</div>;
}Correct (using generated types):
// Import generated types from SpacetimeDB CLI output
import {
DbConnection,
Player,
Message,
GameState,
Identity,
} from './generated';
// Types exactly match server schema
function PlayerCard({ player }: { player: Player }) {
// Full type safety - IDE autocomplete works correctly
return (
<div>
<span>{player.name}</span>
<span>{player.score.toString()}</span>
<span>{player.isOnline ? 'Online' : 'Offline'}</span>
<span>Last seen: {new Date(Number(player.lastSeen)).toLocaleString()}</span>
</div>
);
}
// Connection provides typed access to reducers and tables
function useSendMessage(conn: DbConnection) {
const sendMessage = (channelId: string, content: string) => {
// TypeScript ensures these arguments match server reducer signature
return conn.reducers.sendMessage(channelId, content);
};
return sendMessage;
}
// Type-safe table access
function useOnlinePlayers(conn: DbConnection): Player[] {
// Access tables through the connection's db property
const players = [...conn.db.player.iter()];
// Filter returns correctly typed array
return players.filter(p => p.isOnline);
}# Generate types from your SpacetimeDB module
spacetime generate --lang typescript --out-dir ./src/generated
# Add to package.json scripts for easy regeneration
{
"scripts": {
"generate-types": "spacetime generate --lang typescript --out-dir ./src/generated",
"dev": "npm run generate-types && vite"
}
}// Generated types example (src/generated/index.ts)
// DO NOT EDIT - Auto-generated by SpacetimeDB CLI
export interface Player {
identity: Identity;
name: string;
score: bigint;
isOnline: boolean;
lastSeen: bigint;
createdAt: bigint;
}
export interface Message {
id: string;
channelId: string;
authorId: Identity;
content: string;
timestamp: bigint;
}
export interface GameState {
id: string;
status: string; // 'waiting' | 'active' | 'finished'
currentRound: number;
startedAt: bigint | null;
}
// DbConnection provides typed access to tables and reducers
export interface DbConnection {
db: {
player: TableHandle<Player>;
message: TableHandle<Message>;
gameState: TableHandle<GameState>;
};
reducers: {
sendMessage: (channelId: string, content: string) => Promise<void>;
createGame: (name: string, maxPlayers: number) => Promise<void>;
joinGame: (gameId: string) => Promise<void>;
};
subscription: (query: [string, ...any[]]) => Subscription;
}Regenerate types whenever you:
- Add or modify tables
- Change reducer signatures
- Update column types
- Add or remove indexes
Reference: SpacetimeDB TypeScript Client SDK