@douglance/stdb-react-companion
v1.0.0
Published
Idiomatic React hooks for SpacetimeDB - eliminate boilerplate for state management and subscriptions
Maintainers
Readme
@spacetimedb/react-companion
Idiomatic React hooks for SpacetimeDB applications. Eliminate 200+ lines of boilerplate for state management, subscriptions, and reducer calls.
Installation
npm install @spacetimedb/react-companion spacetimedb reactQuick Start
import { DbConnection } from './generated'; // Your generated SpacetimeDB code
import { SpacetimeDBProvider, useTable, useReducer } from '@spacetimedb/react-companion';
// 1. Create connection
const conn = await DbConnection.builder()
.onConnect((connection, identity) => {
console.log('Connected:', identity);
})
.build();
// 2. Wrap your app
function App() {
return (
<SpacetimeDBProvider connection={conn}>
<GameView />
</SpacetimeDBProvider>
);
}
// 3. Use hooks in components
function GameView() {
const players = useTable<PlayerTag>('playerTag');
const positions = useTable<Position>('position');
const { call: joinGame, isLoading } = useReducer('joinGame');
return (
<div>
<button onClick={() => joinGame({ name: 'Alice' })} disabled={isLoading}>
{isLoading ? 'Joining...' : 'Join Game'}
</button>
{players.map(player => {
const pos = positions.find(p => p.entity_id === player.entity_id);
return (
<div key={player.entity_id}>
{player.name} at ({pos?.x}, {pos?.y})
</div>
);
})}
</div>
);
}API Reference
SpacetimeDBProvider
Provides the SpacetimeDB connection to all child components.
<SpacetimeDBProvider connection={conn}>
<App />
</SpacetimeDBProvider>useTable<T>(tableName: string): T[]
Subscribe to a SpacetimeDB table and get reactive state. Automatically handles:
- Initial data loading
- Insert/update/delete subscriptions
- Component re-renders on changes
- Cleanup on unmount
Important: Use camelCase table names from generated code, not PascalCase from your schema.
// Schema defines: PlayerTag table
// Generated code exports: conn.db.playerTag (camelCase)
// Hook usage:
const players = useTable<PlayerTag>('playerTag'); // ✅ camelCase
const players = useTable<PlayerTag>('PlayerTag'); // ❌ PascalCase failsExamples:
// Subscribe to players
const players = useTable<PlayerTag>('playerTag');
// Subscribe to positions
const positions = useTable<Position>('position');
// Subscribe to lobby state
const lobbies = useTable<Lobby>('lobby');useReducer<TArgs>(name: string)
Call SpacetimeDB reducers with loading and error state management.
Returns: { call, isLoading, error }
const { call: joinGame, isLoading, error } = useReducer('joinGame');
const handleJoin = async () => {
await joinGame({ name: 'Alice' });
};
return (
<div>
<button onClick={handleJoin} disabled={isLoading}>
{isLoading ? 'Joining...' : 'Join Game'}
</button>
{error && <div className="error">{error.message}</div>}
</div>
);useSpacetimeDB(): SpacetimeDBConnection
Access the raw SpacetimeDB connection. Use when you need direct access to conn.db or conn.reducers.
const conn = useSpacetimeDB();
// Direct table access
const playerCount = conn.db.playerTag.count();
// Direct reducer access
await conn.reducers.leaveGame();Before & After
Before (manual state management):
function GameView() {
const [players, setPlayers] = useState(new Map());
const [positions, setPositions] = useState(new Map());
useEffect(() => {
// Subscribe to players
conn.db.playerTag.onInsert((_ctx, player) => {
setPlayers(prev => new Map(prev).set(player.id, player));
});
conn.db.playerTag.onUpdate((_ctx, old, player) => {
setPlayers(prev => new Map(prev).set(player.id, player));
});
conn.db.playerTag.onDelete((_ctx, player) => {
setPlayers(prev => {
const next = new Map(prev);
next.delete(player.id);
return next;
});
});
// Subscribe to positions
conn.db.position.onInsert((_ctx, pos) => {
setPositions(prev => new Map(prev).set(pos.entity_id, pos));
});
conn.db.position.onUpdate((_ctx, old, pos) => {
setPositions(prev => new Map(prev).set(pos.entity_id, pos));
});
conn.db.position.onDelete((_ctx, pos) => {
setPositions(prev => {
const next = new Map(prev);
next.delete(pos.entity_id);
return next;
});
});
}, []);
return (
<div>
{Array.from(players.values()).map(player => (
<div key={player.id}>{player.name}</div>
))}
</div>
);
}After (with @spacetimedb/react-companion):
function GameView() {
const players = useTable<PlayerTag>('playerTag');
const positions = useTable<Position>('position');
return (
<div>
{players.map(player => (
<div key={player.id}>{player.name}</div>
))}
</div>
);
}Common Patterns
Joining Related Tables
function PlayerList() {
const players = useTable<PlayerTag>('playerTag');
const positions = useTable<Position>('position');
return (
<ul>
{players.map(player => {
const pos = positions.find(p => p.entity_id === player.entity_id);
return (
<li key={player.entity_id}>
{player.name} at ({pos?.x ?? 0}, {pos?.y ?? 0})
</li>
);
})}
</ul>
);
}Optimistic Updates
function PlayerActions({ playerId }: { playerId: Identity }) {
const [localHealth, setLocalHealth] = useState<number | null>(null);
const { call: heal } = useReducer('heal');
const players = useTable<PlayerTag>('playerTag');
const player = players.find(p => p.entity_id === playerId);
const displayedHealth = localHealth ?? player?.health ?? 100;
const handleHeal = async () => {
// Optimistic update
setLocalHealth((displayedHealth + 20));
// Server call
await heal({ playerId });
// Clear optimistic state
setLocalHealth(null);
};
return (
<div>
<div>Health: {displayedHealth}</div>
<button onClick={handleHeal}>Heal (+20)</button>
</div>
);
}Error Handling
function JoinButton() {
const { call: joinGame, isLoading, error } = useReducer('joinGame');
const handleJoin = async () => {
try {
await joinGame({ name: 'Alice' });
} catch (err) {
console.error('Failed to join:', err);
}
};
return (
<div>
<button onClick={handleJoin} disabled={isLoading}>
Join Game
</button>
{error && (
<div className="error">
Failed to join: {error.message}
</div>
)}
</div>
);
}TypeScript Support
This package is written in TypeScript with full type safety:
interface PlayerTag {
entity_id: Identity;
name: string;
health: number;
}
// Type-safe table subscription
const players = useTable<PlayerTag>('playerTag'); // players: PlayerTag[]
// Type-safe reducer calls
interface JoinGameArgs {
name: string;
}
const { call: joinGame } = useReducer<[JoinGameArgs]>('joinGame');
await joinGame({ name: 'Alice' }); // ✅ Type-checkedCritical Notes
Table Name Capitalization
SpacetimeDB generates camelCase table accessors, not PascalCase:
| Schema Definition | Generated API | Hook Usage |
|-------------------|---------------|------------|
| PlayerTag table | conn.db.playerTag | useTable('playerTag') ✅ |
| Position table | conn.db.position | useTable('position') ✅ |
Using PascalCase will fail at runtime:
useTable('PlayerTag') // ❌ TypeError: Cannot read properties of undefinedPrimary Keys Enable onUpdate
Tables must have primary keys for onUpdate callbacks:
// ❌ BROKEN - no onUpdate generated
const Position = table({ name: 'Position', public: true }, {
entity_id: t.identity().unique(), // Only generates onInsert/onDelete
x: t.f32(),
y: t.f32(),
});
// ✅ WORKS - onUpdate generated
const Position = table({ name: 'Position', public: true }, {
entity_id: t.identity().primaryKey(), // Generates onInsert/onUpdate/onDelete
x: t.f32(),
y: t.f32(),
});Without primary keys, position updates from the server are silently ignored.
Requirements
- React 18+
- SpacetimeDB 1.6+
- TypeScript 5.0+ (recommended)
License
MIT
