Idiomatic React hooks for SpacetimeDB - eliminate boilerplate for state management and subscriptions
npm install @douglance/stdb-react-companionIdiomatic React hooks for SpacetimeDB applications. Eliminate 200+ lines of boilerplate for state management, subscriptions, and reducer calls.
``bash`
npm install @spacetimedb/react-companion spacetimedb react
`tsx
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 (
);
}
// 3. Use hooks in components
function GameView() {
const players = useTable
const positions = useTable
const { call: joinGame, isLoading } = useReducer('joinGame');
return (
{players.map(player => {
const pos = positions.find(p => p.entity_id === player.entity_id);
return (
API Reference
$3
Provides the SpacetimeDB connection to all child components.
`tsx
`$3
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.
`tsx
// Schema defines: PlayerTag table
// Generated code exports: conn.db.playerTag (camelCase)
// Hook usage:
const players = useTable('playerTag'); // ✅ camelCase
const players = useTable('PlayerTag'); // ❌ PascalCase fails
`Examples:
`tsx
// Subscribe to players
const players = useTable('playerTag');// Subscribe to positions
const positions = useTable('position');
// Subscribe to lobby state
const lobbies = useTable('lobby');
`$3
Call SpacetimeDB reducers with loading and error state management.
Returns:
{ call, isLoading, error }`tsx
const { call: joinGame, isLoading, error } = useReducer('joinGame');const handleJoin = async () => {
await joinGame({ name: 'Alice' });
};
return (
{error && {error.message}}
);
`$3
Access the raw SpacetimeDB connection. Use when you need direct access to
conn.db or conn.reducers.`tsx
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):
`tsx
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 (
{Array.from(players.values()).map(player => (
{player.name}
))}
);
}
`After (with @spacetimedb/react-companion):
`tsx
function GameView() {
const players = useTable('playerTag');
const positions = useTable('position'); return (
{players.map(player => (
{player.name}
))}
);
}
`Common Patterns
$3
`tsx
function PlayerList() {
const players = useTable('playerTag');
const positions = useTable('position'); return (
{players.map(player => {
const pos = positions.find(p => p.entity_id === player.entity_id);
return (
{player.name} at ({pos?.x ?? 0}, {pos?.y ?? 0})
);
})}
);
}
`$3
`tsx
function PlayerActions({ playerId }: { playerId: Identity }) {
const [localHealth, setLocalHealth] = useState(null);
const { call: heal } = useReducer('heal');
const players = useTable('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 (
Health: {displayedHealth}
);
}
`$3
`tsx
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 (
{error && (
Failed to join: {error.message}
)}
);
}
`TypeScript Support
This package is written in TypeScript with full type safety:
`tsx
interface PlayerTag {
entity_id: Identity;
name: string;
health: number;
}// Type-safe table subscription
const players = useTable('playerTag'); // players: PlayerTag[]
// Type-safe reducer calls
interface JoinGameArgs {
name: string;
}
const { call: joinGame } = useReducer<[JoinGameArgs]>('joinGame');
await joinGame({ name: 'Alice' }); // ✅ Type-checked
`Critical Notes
$3
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:
`tsx
useTable('PlayerTag') // ❌ TypeError: Cannot read properties of undefined
`$3
Tables must have primary keys for
onUpdate callbacks:`typescript
// ❌ 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.
- React 18+
- SpacetimeDB 1.6+
- TypeScript 5.0+ (recommended)
MIT