Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/bindings-typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ connection.reducers.createPlayer();

#### React Usage

This module also include React hooks to subscribe to tables under the `spacetimedb/react` subpath. In order to use SpacetimeDB React hooks in your project, first add a `SpacetimeDBProvider` at the top of your component hierarchy:
This module also includes React hooks to subscribe to tables under the `spacetimedb/react` subpath. The React integration is fully compatible with React StrictMode and handles the double-mount behavior correctly (only one WebSocket connection is created).

In order to use SpacetimeDB React hooks in your project, first add a `SpacetimeDBProvider` at the top of your component hierarchy:

```tsx
const connectionBuilder = DbConnection.builder()
Expand Down
125 changes: 42 additions & 83 deletions crates/bindings-typescript/src/react/SpacetimeDBProvider.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {
DbConnectionBuilder,
type DbConnectionImpl,
type ErrorContextInterface,
type RemoteModuleOf,
} from '../sdk/db_connection_impl';
import * as React from 'react';
import { SpacetimeDBContext } from './useSpacetimeDB';
import type { ConnectionState } from './connection_state';
import { ConnectionId } from '../lib/connection_id';
import {
ConnectionManager,
type ConnectionState as ManagerConnectionState,
} from '../sdk/connection_manager';

export interface SpacetimeDBProviderProps<
DbConnection extends DbConnectionImpl<any>,
Expand All @@ -22,104 +24,61 @@ export function SpacetimeDBProvider<
connectionBuilder,
children,
}: SpacetimeDBProviderProps<DbConnection>): React.JSX.Element {
// Holds the imperative connection instance when (and only when) we're on the client.
const connRef = React.useRef<DbConnection | null>(null);
// Used to detect React StrictMode vs real unmounts (see cleanup comment below)
const cleanupTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
const uri = connectionBuilder.getUri();
const moduleName = connectionBuilder.getModuleName();
const key = React.useMemo(
() => ConnectionManager.getKey(uri, moduleName),
[uri, moduleName]
);
const getConnection = React.useCallback(() => connRef.current, []);

const [state, setState] = React.useState<ConnectionState>({
const fallbackStateRef = React.useRef<ManagerConnectionState>({
isActive: false,
identity: undefined,
token: undefined,
connectionId: ConnectionId.random(),
connectionError: undefined,
getConnection: getConnection as ConnectionState['getConnection'],
});

// Build on the client only; useEffect won't run during SSR.
React.useEffect(() => {
// If we're remounting after a StrictMode unmount, cancel the pending disconnect
if (cleanupTimeoutRef.current) {
clearTimeout(cleanupTimeoutRef.current);
cleanupTimeoutRef.current = null;
}
const subscribe = React.useCallback(
(onStoreChange: () => void) =>
ConnectionManager.subscribe(key, onStoreChange),
[key]
);
const getSnapshot = React.useCallback(
() => ConnectionManager.getSnapshot(key) ?? fallbackStateRef.current,
[key]
);
const getServerSnapshot = React.useCallback(
() => fallbackStateRef.current,
[]
);

if (!connRef.current) {
connRef.current = connectionBuilder.build();
}
// Register callback for onConnect to update state
const onConnect = (conn: DbConnection) => {
setState(s => ({
...s,
isActive: conn.isActive,
identity: conn.identity,
token: conn.token,
connectionId: conn.connectionId,
}));
};
const onDisconnect = (
ctx: ErrorContextInterface<RemoteModuleOf<DbConnection>>
) => {
setState(s => ({
...s,
isActive: ctx.isActive,
}));
};
const onConnectError = (
ctx: ErrorContextInterface<RemoteModuleOf<DbConnection>>,
err: Error
) => {
setState(s => ({
...s,
isActive: ctx.isActive,
connectionError: err,
}));
};
connectionBuilder.onConnect(onConnect);
connectionBuilder.onDisconnect(onDisconnect);
connectionBuilder.onConnectError(onConnectError);
const state = React.useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
);

const conn = connRef.current;
setState(s => ({
...s,
isActive: conn.isActive,
identity: conn.identity,
token: conn.token,
connectionId: conn.connectionId,
}));
const getConnection = React.useCallback(
() => ConnectionManager.getConnection<DbConnection>(key),
[key]
);

return () => {
connRef.current?.removeOnConnect(onConnect as any);
connRef.current?.removeOnDisconnect(onDisconnect as any);
connRef.current?.removeOnConnectError(onConnectError as any);
const contextValue = React.useMemo<ConnectionState>(
() => ({ ...state, getConnection }),
[state, getConnection]
);

// Detect React StrictMode vs real unmounts using a deferred disconnect.
//
// In StrictMode, React unmounts and remounts components synchronously
// (in the same JavaScript task) to help detect side-effect issues.
// By deferring disconnect with setTimeout(..., 0), we push it to the
// next task in the event loop. This lets us distinguish:
//
// - StrictMode (fake unmount): cleanup runs → timeout scheduled →
// remount happens immediately (same task) → remount cancels timeout →
// connection survives
//
// - Real unmount: cleanup runs → timeout scheduled → no remount →
// timeout fires → connection is properly closed
cleanupTimeoutRef.current = setTimeout(() => {
connRef.current?.disconnect();
connRef.current = null;
cleanupTimeoutRef.current = null;
}, 0);
React.useEffect(() => {
ConnectionManager.retain(key, connectionBuilder);
return () => {
ConnectionManager.release(key);
};
}, [connectionBuilder]);
}, [key, connectionBuilder]);

return React.createElement(
SpacetimeDBContext.Provider,
{ value: state },
{ value: contextValue },
children
);
}
14 changes: 3 additions & 11 deletions crates/bindings-typescript/src/react/connection_state.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import type { ConnectionId } from '../lib/connection_id';
import type { Identity } from '../lib/identity';
import type { DbConnectionImpl } from '../sdk/db_connection_impl';
import type { ConnectionState as ManagerConnectionState } from '../sdk/connection_manager';

export type ConnectionState = {
isActive: boolean;
identity?: Identity;
token?: string;
connectionId: ConnectionId;
connectionError?: Error;
getConnection<
DbConnection extends DbConnectionImpl<any>,
>(): DbConnection | null;
export type ConnectionState = ManagerConnectionState & {
getConnection(): DbConnectionImpl<any> | null;
};
Loading