spacetimedb-typescript について
spacetimedb-typescriptは、SpacetimeDBモジュール向けの型安全なTypeScriptクライアントを構築するスキルとリファレンスです。生成バインディング、データ購読、リアクティブな更新、APIの注意点を説明します。
主な機能
- 01TypeScriptバインディングの生成
- 02SpacetimeDBモジュールへの接続
- 03SQLで絞り込んだテーブルデータの購読
- 04リアルタイムのテーブル変更イベント処理
- 05React hooksとproviderの提供
- 06APIの注意点と制約の説明
活用シーン
- 01SpacetimeDBデータを使うTypeScriptアプリの構築
- 02クライアントのテーブル状態の同期
- 03リアクティブなReact画面の作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ douglance/ spacetimedb --skill spacetimedb-typescriptインストール後、Claude Code で/spacetimedb-typescriptを入力して読み込まれたか確認します。
詳細
SpacetimeDB TypeScript SDK
SpacetimeDB モジュールに直接接続する、リアルタイムの TypeScript クライアントを構築できます。この SDK は、Web アプリ、Node.js、Deno、Bun、その他の JavaScript ランタイム向けに、型安全なデータベースアクセス、自動同期、リアクティブな更新を提供します。
存在しない API — 使用しないでください
これらの API は存在しません。LLM は頻繁にこれらを幻覚します。
// WRONG PACKAGE — does not exist
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
// WRONG — these methods don't exist
SpacetimeDBClient.connect(...);
SpacetimeDBClient.call("reducer_name", [...]);
connection.call("reducer_name", [arg1, arg2]);
// WRONG — positional reducer arguments
conn.reducers.doSomething("value"); // WRONG!
正しいパターン:
// CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
// CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// CORRECT DATA ACCESS — useTable returns [rows, isLoading]
const [items, isLoading] = useTable(tables.item);
次のことはしないでください:
useItems()、useData()のようなフックを作り出さないでください —useTable(tables.tableName)を使用してください- 架空のパッケージからインポートしないでください —
spacetimedb、spacetimedb/react、./module_bindingsのみを使用してください
よくある間違いの一覧
サーバー側のエラー
| 誤り | 正しい方法 | エラー |
|---|---|---|
package.json がない | package.json を作成する | "could not detect language" |
tsconfig.json がない | tsconfig.json を作成する | "TsconfigNotFound" |
エントリーポイントが src/index.ts にない | src/index.ts を使用する | モジュールをバンドルできません |
COLUMNS(第2引数)に indexes を指定 | OPTIONS(第1引数)に indexes を指定 | "reading 'tag'" エラー |
algorithm のないインデックス | algorithm: 'btree' | "reading 'tag'" エラー |
filter({ ownerId }) | filter(ownerId) | "does not exist in type 'Range'" |
unique カラムに .filter() | unique カラムに .find() | TypeError |
insert({ ...without id }) | insert({ id: 0n, ... }) | "Property 'id' is missing" |
const id = table.insert(...) | const row = table.insert(...) | .insert() は ID ではなく ROW を返します |
.unique() と明示的なインデックス | .unique() のみを使用する | "name is used for multiple entities" |
| index.ts から spacetimedb をインポート | schema.ts からインポート | "Cannot access before initialization" |
複数カラムのインデックスで .filter() | 単一カラムのインデックスを使用する | PANIC または結果が黙って空になります |
ビュー内で .iter() を使用 | インデックス参照のみを使用する | ビューはテーブルをスキャンできません |
プロシージャ内で ctx.db を使用 | ctx.withTx(tx => tx.db...) | プロシージャには明示的なトランザクションが必要です |
プロシージャのトランザクション内で ctx.myTable を使用 | tx.db.myTable | コンテキスト変数が間違っています |
クライアント側のエラー
| 誤り | 正しい方法 | エラー |
|---|---|---|
@spacetimedb/sdk | spacetimedb | 404 / サブパスがありません |
conn.reducers.foo("val") | conn.reducers.foo({ param: "val" }) | Reducer の構文が間違っています |
connectionBuilder をインラインで指定 | useMemo(() => ..., []) | レンダリングごとに再接続されます |
const rows = useTable(table) | const [rows, isLoading] = useTable(table) | タプルの分割代入 |
| 楽観的 UI 更新 | サブスクリプションに状態を反映させる | 同期ずれの問題 |
<SpacetimeDBProvider builder={...}> | connectionBuilder={...} | props 名が間違っています |
必須要件
- 生成されたバインディングを編集しないでください —
spacetime generateで再生成してください - Reducer はトランザクションです — データを返しません
- Reducer は決定論的である必要があります — ファイルシステム、ネットワーク、タイマー、乱数は使用できません
- Reducer の呼び出しにはオブジェクト構文を使用します — 位置引数ではなく
{ param: 'value' }を使用してください DbConnectionは./module_bindingsからインポートしてください —spacetimedbからではありません- useTable はタプルを返します —
const [rows, isLoading] = useTable(tables.myTable) - connectionBuilder をメモ化してください — 再接続を防ぐため、
useMemo(() => ..., [])でラップしてください - ビューで使用できるのはインデックス参照のみです — ビュー内で
.iter()は使用できません
インストール
npm install spacetimedb
# or
pnpm add spacetimedb
# or
yarn add spacetimedb
Node.js 18-21 では、ピア依存関係の undici をインストールしてください:
npm install spacetimedb undici
Node.js 22 以降およびブラウザー環境では、そのまま動作します。
型バインディングの生成
SDK を使用する前に、SpacetimeDB モジュールから TypeScript バインディングを生成してください:
spacetime generate --lang typescript --out-dir ./src/module_bindings --project-path ./server
これにより、次の内容を含む module_bindings ディレクトリが作成されます:
index.ts-DbConnection、tables、reducers、queryを含むメインのエクスポート- 各テーブルの型定義(例:
player_table.ts、user_table.ts) - 各 Reducer の型定義(例:
create_player_reducer.ts) - カスタム型の定義(例:
point_type.ts)
基本的な接続の設定
import { DbConnection } from './module_bindings';
const connection = DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('my_database')
.onConnect((conn, identity, token) => {
console.log('Connected with identity:', identity.toHexString());
// Store token for reconnection
localStorage.setItem('spacetimedb_token', token);
// Subscribe to tables after connection
conn.subscriptionBuilder().subscribe('SELECT * FROM player');
})
.onDisconnect((ctx) => {
console.log('Disconnected');
})
.onConnectError((ctx, error) => {
console.error('Connection error:', error);
})
.build();
Connection Builder のオプション
DbConnection.builder()
// Required: SpacetimeDB server URI
.withUri('ws://localhost:3000')
// Required: Database module name or address
.withModuleName('my_database')
// Optional: Authentication token for reconnection
.withToken(localStorage.getItem('spacetimedb_token') ?? undefined)
// Optional: Enable compression (default: 'gzip')
.withCompression('gzip') // or 'none'
// Optional: Light mode reduces network traffic
.withLightMode(true)
// Optional: Wait for durable writes before receiving updates
.withConfirmedReads(true)
// Connection lifecycle callbacks
.onConnect((conn, identity, token) => { /* ... */ })
.onDisconnect((ctx, error) => { /* ... */ })
.onConnectError((ctx, error) => { /* ... */ })
.build();
テーブルのサブスクライブ
サブスクリプションはテーブルデータをクライアントキャッシュに同期します。SQL クエリを使って、受信するデータを絞り込みます。
基本的なサブスクリプション
connection.subscriptionBuilder()
.onApplied((ctx) => {
console.log('Subscription applied, cache is ready');
})
.onError((ctx, error) => {
console.error('Subscription error:', error);
})
.subscribe('SELECT * FROM player');
複数のクエリ
connection.subscriptionBuilder()
.subscribe([
'SELECT * FROM player',
'SELECT * FROM game_state',
'SELECT * FROM message WHERE room_id = 1'
]);
型付きクエリビルダー
型安全なクエリには、生成された query オブジェクトを使います。
import { query } from './module_bindings';
// Simple query - selects all rows
connection.subscriptionBuilder()
.subscribe(query.player.build());
// Query with WHERE clause
connection.subscriptionBuilder()
.subscribe(
query.player
.where(row => row.name.eq('Alice'))
.build()
);
// Complex conditions
connection.subscriptionBuilder()
.subscribe(
query.player
.where(row => row.score.gte(100))
.where(row => row.isActive.eq(true))
.build()
);
すべてのテーブルをサブスクライブ
開発環境や小規模なデータセットで使用します。
connection.subscriptionBuilder().subscribeToAllTables();
サブスクライブ解除
const handle = connection.subscriptionBuilder()
.onApplied(() => console.log('Subscribed'))
.subscribe('SELECT * FROM player');
// Later, unsubscribe
handle.unsubscribe();
// Or with callback when complete
handle.unsubscribeThen((ctx) => {
console.log('Unsubscribed successfully');
});
テーブルデータへのアクセス
サブスクリプション後は、connection.db を通じてキャッシュされたデータにアクセスします。
// Iterate all rows
for (const player of connection.db.player.iter()) {
console.log(player.name, player.score);
}
// Convert to array
const players = Array.from(connection.db.player.iter());
// Count rows
const count = connection.db.player.count();
// Find by primary key (if table has one)
const player = connection.db.player.id.find(42);
// Find by indexed column
const alice = connection.db.player.name.find('Alice');
テーブルイベントのコールバック
テーブルデータのリアルタイムな変更を監視します。
// Row inserted
connection.db.player.onInsert((ctx, player) => {
console.log('New player:', player.name);
});
// Row deleted
connection.db.player.onDelete((ctx, player) => {
console.log('Player left:', player.name);
});
// Row updated (requires primary key on table)
connection.db.player.onUpdate((ctx, oldPlayer, newPlayer) => {
console.log(`${oldPlayer.name} score: ${oldPlayer.score} -> ${newPlayer.score}`);
});
// Remove callbacks
const onInsertCb = (ctx, player) => console.log(player);
connection.db.player.onInsert(onInsertCb);
connection.db.player.removeOnInsert(onInsertCb);
イベントコンテキスト
コールバックは、イベントに関する情報を含む EventContext を受け取ります。
connection.db.player.onInsert((ctx, player) => {
// Access to database
const allPlayers = Array.from(ctx.db.player.iter());
// Check event type
if (ctx.event.tag === 'Reducer') {
const { callerIdentity, reducer, status } = ctx.event.value;
console.log(`Triggered by reducer: ${reducer.name}`);
}
// Call other reducers
ctx.reducers.sendMessage({ playerId: player.id, text: 'Welcome!' });
});
Reducer の呼び出し
Reducer はデータベースを変更するサーバーサイド関数です。重要:位置引数ではなく、オブジェクト構文を使ってください。
// CORRECT: Object syntax
connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } });
// WRONG: Positional arguments
// connection.reducers.createPlayer('Alice', { x: 0, y: 0 }); // DO NOT DO THIS
// Listen for reducer results
connection.reducers.onCreatePlayer((ctx, args) => {
const { callerIdentity, status, timestamp, energyConsumed } = ctx.event;
if (status.tag === 'Committed') {
console.log('Player created successfully');
} else if (status.tag === 'Failed') {
console.error('Failed:', status.value);
}
});
// Remove reducer callback
connection.reducers.removeOnCreatePlayer(callback);
snake_case から camelCase への変換
- サーバー:
spacetimedb.reducer('do_something', ...) - クライアント:
conn.reducers.doSomething({ ... })
Reducer フラグ
サーバーによる Reducer 呼び出しの処理方法を制御します。
// NoSuccessNotify: Don't send TransactionUpdate on success (reduces traffic)
connection.setReducerFlags.movePlayer('NoSuccessNotify');
// FullUpdate: Always send full TransactionUpdate (default)
connection.setReducerFlags.movePlayer('FullUpdate');
ビュー
ビューを使用すると、接続中のユーザーに基づいて、非公開テーブルデータにフィルターを適用してアクセスできます。
ViewContext と AnonymousViewContext
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
重要:ビューではインデックスルックアップのみ使用できます
// WRONG — views cannot use .iter()
spacetimedb.view(
{ name: 'my_data_wrong', public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.iter()] // NOT ALLOWED
);
// RIGHT — use index lookup
spacetimedb.view(
{ name: 'my_data', public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
);
ビューのサブスクライブ
ビューを使用するには、明示的なサブスクリプションが必要です。
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
]);
プロシージャ(ベータ版)
プロシージャは、Reducer では実行できない副作用(HTTP リクエストなど)に使用します。
プロシージャは現在ベータ版です。API は変更される可能性があります。
プロシージャの定義
spacetimedb.procedure(
'fetch_external_data',
{ url: t.string() },
t.string(), // return type
(ctx, { url }) => {
const response = ctx.http.fetch(url);
return response.text();
}
);
重要:プロシージャでのデータベースアクセス
プロシージャには ctx.db がありません。データベースにアクセスするには ctx.withTx() を使用してください。
spacetimedb.procedure('save_fetched_data', { url: t.string() }, t.unit(), (ctx, { url }) => {
// Fetch external data (outside transaction)
const response = ctx.http.fetch(url);
const data = response.text();
// WRONG — ctx.db doesn't exist in procedures
// ctx.db.myTable.insert({ ... });
// RIGHT — use ctx.withTx() for database access
ctx.withTx(tx => {
tx.db.myTable.insert({
id: 0n,
content: data,
fetchedAt: tx.timestamp,
fetchedBy: tx.sender,
});
});
return {};
});
Reducer との主な違い
| Reducer | プロシージャ |
|---|---|
ctx.db を直接利用可能 | ctx.withTx(tx => tx.db...) を使用する必要がある |
| 自動トランザクション | 手動でのトランザクション管理 |
| HTTP/ネットワーク不可 | ctx.http.fetch() を利用可能 |
| 呼び出し元に戻り値を返さない | 呼び出し元にデータを返せる |
ID と認証
import { Identity } from 'spacetimedb';
// Get current identity
const identity = connection.identity;
console.log(identity?.toHexString());
// Compare identities
if (identity?.isEqual(otherIdentity)) {
console.log('Same user');
}
// Create from hex string
const parsed = Identity.fromString('0x1234...');
// Zero identity
const zero = Identity.zero();
// Compare identities using toHexString()
const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
認証情報の永続化
// On connect, save the token
.onConnect((conn, identity, token) => {
localStorage.setItem('auth_token', token);
localStorage.setItem('identity', identity.toHexString());
})
// On reconnect, use saved token
.withToken(localStorage.getItem('auth_token') ?? undefined)
古いトークンの処理
const onConnectError = (_ctx: ErrorContext, err: Error) => {
if (err.message?.includes('Unauthorized') || err.message?.includes('401')) {
localStorage.removeItem('auth_token');
window.location.reload();
}
};
React 連携
SDK には、リアクティブな UI 更新のための React フックが含まれています。
プロバイダーの設定
import React, { useMemo } from 'react';
import ReactDOM from 'react-dom/client';
import { SpacetimeDBProvider } from 'spacetimedb/react';
import { DbConnection, query } from './module_bindings';
import App from './App';
function Root() {
// CRITICAL: Memoize to prevent reconnects on every render
const connectionBuilder = useMemo(() =>
DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('my_game')
.withToken(localStorage.getItem('auth_token') || undefined)
.onConnect((conn, identity, token) => {
console.log('Connected:', identity.toHexString());
localStorage.setItem('auth_token', token);
conn.subscriptionBuilder().subscribe(query.player.build());
})
.onDisconnect(() => console.log('Disconnected'))
.onConnectError((ctx, err) => console.error('Error:', err)),
[] // Empty deps - only create once
);
return (
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
<App />
</SpacetimeDBProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>
);
useSpacetimeDB フック
接続状態にアクセスします。
import { useSpacetimeDB } from 'spacetimedb/react';
function ConnectionStatus() {
const { isActive, identity, token, connectionId, connectionError } = useSpacetimeDB();
if (connectionError) {
return <div>Error: {connectionError.message}</div>;
}
if (!isActive) {
return <div>Connecting...</div>;
}
return <div>Connected as {identity?.toHexString()}</div>;
}
useTable フック
リアクティブな更新を伴うテーブルデータをサブスクライブします。重要:[rows, isLoading] のタプルを返します。
import { useTable, where, eq } from 'spacetimedb/react';
import { tables } from './module_bindings';
function PlayerList() {
// CORRECT: Tuple destructuring
const [players, isLoading] = useTable(tables.player);
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{players.map(player => (
<li key={player.id}>{player.name}: {player.score}</li>
))}
</ul>
);
}
function FilteredPlayerList() {
// Filtered players with callbacks
const [activePlayers, isLoading] = useTable(
tables.player,
where(eq('isActive', true)),
{
onInsert: (player) => console.log('Player joined:', player.name),
onDelete: (player) => console.log('Player left:', player.name),
onUpdate: (oldPlayer, newPlayer) => {
console.log(`${oldPlayer.name} updated`);
},
}
);
return (
<ul>
{activePlayers.map(player => (
<li key={player.id}>{player.name}</li>
))}
</ul>
);
}
useReducer フック
コンポーネントから Reducer を呼び出します。
import { useReducer } from 'spacetimedb/react';
import { reducers } from './module_bindings';
function CreatePlayerForm() {
const createPlayer = useReducer(reducers.createPlayer);
const [name, setName] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
// CORRECT: Object syntax
createPlayer({ name, location: { x: 0, y: 0 } });
setName('');
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} />
<button type="submit">Create Player</button>
</form>
);
}
Vue 連携
SDK には Vue コンポーザブルが含まれています。
import { SpacetimeDBProvider, useSpacetimeDB, useTable, useReducer } from 'spacetimedb/vue';
使い方は React フックと似ています。
Svelte 連携
SDK には Svelte ストアが含まれています。
import { SpacetimeDBProvider, useSpacetimeDB, useTable, useReducer } from 'spacetimedb/svelte';
サーバーサイドでの使用(Node.js、Deno、Bun)
SDK はサーバーサイドの JavaScript ランタイムで動作します。
import { DbConnection } from './module_bindings';
async function main() {
const connection = DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('my_database')
.onConnect((conn, identity, token) => {
console.log('Connected:', identity.toHexString());
conn.subscriptionBuilder()
.onApplied(() => {
// Process data
for (const player of conn.db.player.iter()) {
console.log(player);
}
})
.subscribe('SELECT * FROM player');
})
.build();
}
main();
タイムスタンプ
サーバーサイド
import { Timestamp, ScheduleAt } from 'spacetimedb';
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Future time (add microseconds)
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
クライアントサイド(重要)
タイムスタンプは数値ではなく、オブジェクトです。
// WRONG
const date = new Date(row.createdAt);
const date = new Date(Number(row.createdAt / 1000n));
// RIGHT
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
クライアントでの ScheduleAt
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
スケジュールテーブル
// Scheduled table MUST use scheduledId and scheduledAt columns
export const CleanupJob = table({
name: 'cleanup_job',
scheduled: 'run_cleanup' // reducer name
}, {
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(), // Your custom data
});
// Scheduled reducer receives full row as arg
spacetimedb.reducer('run_cleanup', { arg: CleanupJob.rowType }, (ctx, { arg }) => {
// arg.scheduledId, arg.targetId available
// Row is auto-deleted after reducer completes
});
// Schedule a job
import { ScheduleAt } from 'spacetimedb';
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(futureTime),
targetId: someId
});
// Cancel a job by deleting the row
ctx.db.cleanupJob.scheduledId.delete(jobId);
エラー処理
接続エラー
DbConnection.builder()
.onConnectError((ctx, error) => {
console.error('Failed to connect:', error.message);
// Implement retry logic
setTimeout(() => {
// Rebuild connection
}, 5000);
})
.build();
サブスクリプションエラー
connection.subscriptionBuilder()
.onError((ctx, error) => {
console.error('Subscription failed:', error.message);
})
.subscribe('SELECT * FROM player');
Reducer エラー
connection.reducers.onCreatePlayer((ctx, args) => {
const { status } = ctx.event;
switch (status.tag) {
case 'Committed':
console.log('Success');
break;
case 'Failed':
console.error('Reducer failed:', status.value);
break;
case 'OutOfEnergy':
console.error('Out of energy');
break;
}
});
切断
// Gracefully disconnect
connection.disconnect();
型リファレンス
コア型
import {
Identity, // User identity (256-bit)
ConnectionId, // Connection identifier
Timestamp, // SpacetimeDB timestamp
TimeDuration, // Duration type
Uuid, // UUID type
} from 'spacetimedb';
生成された型
// From your module_bindings
import {
DbConnection, // Connection class
DbConnectionBuilder, // Builder class
SubscriptionBuilder, // Subscription builder
SubscriptionHandle, // Subscription handle
EventContext, // Event callback context
ReducerEventContext, // Reducer callback context
ErrorContext, // Error callback context
tables, // Table accessors for useTable
reducers, // Reducer definitions for useReducer
query, // Typed query builder
// Your custom types
Player,
Point,
// ... etc
} from './module_bindings';
コマンド
# Start local server
spacetime start
# Publish module
spacetime publish <module-name> --project-path <backend-dir>
# Clear database and republish
spacetime publish <module-name> --clear-database -y --project-path <backend-dir>
# Generate bindings
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --project-path <backend-dir>
# View logs
spacetime logs <module-name>
ベストプラクティス
-
認証トークンを保存する:
onConnectから取得したトークンを保存し、シームレスに再接続できるようにします。 -
接続後にサブスクライブする:
onConnectコールバックでサブスクリプションを設定します。 -
型付きクエリを使用する: 型安全性を確保するため、生の SQL 文字列よりも
queryビルダーを優先します。 -
すべての接続状態を処理する:
onConnect、onDisconnect、onConnectErrorを実装します。 -
高頻度の更新にはライトモードを使用する: ゲームやリアルタイムアプリでは
.withLightMode(true)を有効にします。 -
不要になったらサブスクライブを解除する: コンポーネントのアンマウント時やデータが不要になったときに、サブスクリプションをクリーンアップします。
-
主キーを使用する:
onUpdateコールバックを有効にするため、テーブルに主キーを定義します。 -
connectionBuilder をメモ化する: 再接続を防ぐため、必ず
useMemo()でラップします。 -
サブスクリプションで状態を駆動する: 楽観的更新は避け、サーバーを信頼できる唯一の情報源とします。
よくあるパターン
再接続ロジック
function createConnection(token?: string) {
return DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('my_database')
.withToken(token)
.onConnect((conn, identity, newToken) => {
localStorage.setItem('token', newToken);
setupSubscriptions(conn);
})
.onDisconnect(() => {
// Reconnect after delay
setTimeout(() => {
createConnection(localStorage.getItem('token') ?? undefined);
}, 3000);
})
.build();
}
楽観的更新
function PlayerScore({ player }) {
const updateScore = useReducer(reducers.updateScore);
const [optimisticScore, setOptimisticScore] = useState(player.score);
const handleClick = () => {
setOptimisticScore(prev => prev + 1);
updateScore({ playerId: player.id, delta: 1 });
};
// Sync with actual data
useEffect(() => {
setOptimisticScore(player.score);
}, [player.score]);
return <div onClick={handleClick}>Score: {optimisticScore}</div>;
}
複数の条件によるフィルタリング
// Using query builder
query.player
.where(row => row.team.eq('red'))
.where(row => row.score.gte(100))
.build();
// Using React hooks
const [redTeamHighScorers] = useTable(
tables.player,
where(eq('team', 'red')), // Additional filtering in client
);
const filtered = redTeamHighScorers.filter(p => p.score >= 100);
プロジェクト構成
サーバー(backend/spacetimedb/)
src/schema.ts -> Tables, export spacetimedb
src/index.ts -> Reducers, lifecycle, import schema
package.json -> { "type": "module", "dependencies": { "spacetimedb": "^1.11.0" } }
tsconfig.json -> Standard config
循環インポートの回避
schema.ts -> defines tables AND exports spacetimedb
index.ts -> imports spacetimedb from ./schema, defines reducers
クライアント(client/)
src/module_bindings/ -> Generated (spacetime generate)
src/main.tsx -> Provider, connection setup
src/App.tsx -> UI components
src/config.ts -> MODULE_NAME, SPACETIMEDB_URI