If you've followed my previous guide on connecting a Next.js app to SpacetimeDB, you may have hit a snag when trying to authenticate users—particularly if you're using Auth0.
The problem? Getting the token to authenticate the user requires a server endpoint, which in turn makes the getDbConnection method async. And once that happens, a lot of stuff that previously "just worked" suddenly becomes a mess. Managing asynchronous logic on the client? It's not exactly a developer's dream.
So, this guide is here to walk you through how to update the previous code to work smoothly with asynchronous authentication. Hopefully, it'll save you from having to duct-tape Promises and React components together like a last-minute hackathon project.
If you're new here or just want to follow along with the code, you can find both the previous and updated versions on GitHub. This commit highlights all the changes between the two versions.
Replacing the SpacetimeDB provider
SpacetimeDB loads all its subscribed data at initialization. Because of this, we want to wait for that initial load to complete before the user interacts with the app. To handle this properly, we’ll replace the previous SpacetimeDBProvider with one that allows us to manage this loading state more effectively.
We’ll start by creating a new folder to house all the related code. Inside a new contexts/spacetimeDB folder, create the following four files:
spacetimeDBContexts.tsspacetimeDBHooks.tsspacetimeDBProvider.tsxdataPreloader.tsx
Contexts
In spacetimeDBContexts.ts, we define two contexts: one to track initialization status and another to hold the active SpacetimeDB client.
The first context, InitStatusContext, tracks the loading state and any errors that might occur during initialization:
// contexts/spacetimeDB/spacetimeDBContexts.ts
type InitStatusContextType = {
isLoading: boolean;
error: Error | null;
};
export const InitStatusContext = createContext<InitStatusContextType>({
isLoading: true,
error: null,
});
// Second context ...
The second context, SpacetimeDBContext, holds the actual SpacetimeDB client. It starts as null and is updated once the client is initialized:
// contexts/spacetimeDB/spacetimeDBContexts.ts
// ... First context
type SpacetimeDBContextType = {
client: DbConnection;
};
// This context is only created when DbConnection is available
export const SpacetimeDBContext = createContext<SpacetimeDBContextType | null>(null);
Hooks
In spacetimeDBHooks.ts, we define two custom hooks to expose the above contexts.
-
useSpacetimeDB: Returns the active client. If the context hasn’t been initialized, it throws an error. -
useSpacetimeDBStatus: Gives you access to the current loading and error state.
export const useSpacetimeDB = (): DbConnection => {
const context = useContext(SpacetimeDBContext);
if (!context) {
throw new Error('useSpacetimeDB must be used within a SpacetimeDBProvider after initialization');
}
return context.client;
};
export const useSpacetimeDBStatus = () => useContext(InitStatusContext);
The new Provider
The spacetimeDBProvider.tsx component is responsible for creating the client, handling loading and error states, and rendering the appropriate UI based on the state of the connection.
It uses three pieces of state:
-
clientto hold the initialized connection -
isLoadingto track the loading status -
errorto capture any issues during setup
Here’s what it looks like:
// contexts/spacetimeDB/spacetimeDBProvider.tsx
'use client';
type SpacetimeDBProviderProps = {
children: ReactNode;
};
export function SpacetimeDBProvider({ children }: SpacetimeDBProviderProps) {
const [client, setClient] = useState<DbConnection | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
// Code shown below
}, []);
if (isLoading) {
return (
<InitStatusContext.Provider value={{ isLoading, error }}>
<SpacetimeDBLoadingScreen />
</InitStatusContext.Provider>
);
}
if (error) {
return (
<InitStatusContext.Provider value={{ isLoading, error }}>
<SpacetimeDBErrorScreen error={error}/>
</InitStatusContext.Provider>
);
}
if (!client) {
// This should technically never happen since we check isLoading and error first
return (
<InitStatusContext.Provider value={{ isLoading, error }}>
<div>Unexpected state: Client not available but not loading or error</div>
</InitStatusContext.Provider>
);
}
// If we reach here, we definitely have a client
return (
<InitStatusContext.Provider value={{ isLoading, error }}>
<SpacetimeDBContext.Provider value={{ client }}>
<DataPreloader> {/* Initiate all data stores */}
{children}
</DataPreloader>
</SpacetimeDBContext.Provider>
</InitStatusContext.Provider>
);
}
The SpacetimeDBLoadingScreen and SpacetimeDBErrorScreen components can be found on GitHub. We’ll look at DataPreloader shortly. Also note that the old spacetimeDBProvider can now be removed.
Initializing the Client
In the useEffect block, we handle client setup. Here's what it does:
- Tracks a local
isMountedflag to prevent setting state after unmounting. - Calls
getDbConnectionasynchronously. - Subscribe to connection events to detect changes in connection status.
- Subscribes to subscription events to detect when initial data is loaded.
- Cleans up by disconnecting the client when the component unmounts.
// To be added above
useEffect(() => {
let isMounted = true;
const initializeClient = async () => {
try {
if (isMounted) setIsLoading(true);
const dbConnection = await getDbConnection();
if (isMounted) {
setClient(dbConnection);
}
} catch (e) {
if (isMounted) {
setError(e instanceof Error ? e : new Error(String(e)));
setIsLoading(false);
console.error('[SpacetimeDB] Initialization error:', e);
}
}
};
onConnectionDisconnected(() => {
if(!isMounted) return;
setClient(null);
setIsLoading(false);
setError(new Error('Disconnected from SpacetimeDB.'));
});
onConnectionError((error) => {
if(!isMounted) return;
setClient(null);
setIsLoading(false);
setError(error);
})
onSubscriptionApplied(() => {
if(!isMounted) return;
setIsLoading(false);
})
onSubscriptionError((error) => {
if(!isMounted) return;
setIsLoading(false);
setError(error);
})
initializeClient();
return () => {
isMounted = false;
disconnectDbConnection();
};
}, []);
Data Preloader
The DataPreloader component ensures that any SpacetimeDB-based hooks are called as early as possible—ideally before the app renders interactive content. This helps prevent components from having to wait for individual data stores.
// contexts/spacetimeDB/dataPreloader.ts
export const DataPreloader = ({ children }: { children: React.ReactNode }) => {
// These hooks preload data from SpacetimeDB
useMessages()
return <>{children}</>;
};
Whenever you add a new hook that initializes data from SpacetimeDB, make sure to call it in DataPreloader.
Updating Connection Events
Now that the connection status is handled by the SpacetimeDBProvider, we can simplify the existing connectionEvents. Their only job now is to pass along events to the provider—no more direct status tracking.
// /lib/spacetimedb/connectionEvents.ts
const connectionEstablishListeners = new Set<() => void>();
const connectionDisconnectedListeners = new Set<() => void>();
const connectionErrorListeners = new Set<(error: Error) => void>();
export const onConnectionEstablished = (callback: () => void) => {
connectionEstablishListeners.add(callback);
return () => connectionEstablishListeners.delete(callback);
};
export const onConnectionDisconnected = (callback: () => void) => {
connectionDisconnectedListeners.add(callback);
return () => connectionDisconnectedListeners.delete(callback);
};
export const onConnectionError = (callback: (error: Error) => void) => {
connectionErrorListeners.add(callback);
return () => connectionErrorListeners.delete(callback);
};
export const notifyConnectionEstablished = () => {
connectionEstablishListeners.forEach(callback => callback());
};
export const notifyConnectionDisconnected = () => {
connectionDisconnectedListeners.forEach(callback => callback());
};
export const notifyConnectionError = (error: Error) => {
connectionErrorListeners.forEach(callback => callback(error));
};
export const cleanupConnectionListener = () => {
connectionEstablishListeners.clear();
connectionDisconnectedListeners.clear();
connectionErrorListeners.clear();
}
We’ll do the same for subscriptionEvents.ts. These listeners forward subscription state events without holding any internal logic themselves.
// /lib/spacetimedb/subscriptionEvents.ts
const onSubscriptionAppliedListeners = new Set<() => void>();
const onSubscriptionErrorListeners = new Set<(error: Error) => void>();
export const onSubscriptionApplied = (callback: () => void) => {
onSubscriptionAppliedListeners.add(callback);
return () => onSubscriptionAppliedListeners.delete(callback);
};
export const onSubscriptionError = (callback: (error: Error) => void) => {
onSubscriptionErrorListeners.add(callback);
return () => onSubscriptionErrorListeners.delete(callback);
};
export const notifySubscriptionApplied = () => {
onSubscriptionAppliedListeners.forEach(callback => callback());
};
export const notifySubscriptionError = (error: Error) => {
onSubscriptionErrorListeners.forEach(callback => callback(error));
};
export const cleanupSubscriptionListener = () => {
onSubscriptionErrorListeners.clear();
}
Now that all connection status updates are managed via events, we can strip the old status logic from connectionHandlers.ts. We’ll also improve error forwarding to make things more transparent.
// /lib/spacetimedb/connectionHandlers.ts
export const onConnect = (
conn: DbConnection,
identity: Identity,
token: string) => {
console.log('[SpacetimeDB] Connection established.');
localStorage.setItem('auth_token', token);
notifyConnectionEstablished();
subscribeToQueries(conn, ['SELECT * FROM user', 'SELECT * FROM message'])
};
export const onDisconnect = () => {
console.warn('[SpacetimeDB] Disconnected.');
notifyConnectionDisconnected();
};
export const onConnectError = (ctx: ErrorContext, error: Error) => {
console.error('[SpacetimeDB] Connection Error:', error);
notifyConnectionError(error);
};
export const subscribeToQueries = (conn: DbConnection, queries: string[]) => {
conn
?.subscriptionBuilder()
.onApplied(() => {
console.log('[SpacetimeDB] Subscribed to queries.');
notifySubscriptionApplied();
})
.onError((ctx: ErrorContextInterface<RemoteTables, RemoteReducers, SetReducerFlags>) => {
console.error('[SpacetimeDB] Error subscribing to SpacetimeDB ' + ctx.event)
notifySubscriptionError(ctx.event instanceof Error ? ctx.event : new Error(String(ctx.event)));
})
.subscribe(queries);
};
Removing Old Connection State Code
Now that connection state is handled by the provider, we can clean up legacy logic:
-
Delete
useConnectionandconnectionStore. -
Update any components that used to care about connection state—like
MessageList.
Here’s what the updated MessageList looks like now:
// /components/messageList.tsx
export default function MessageList() {
const allMessages = useMessages();
return (
<div>
<button className={"border p-2"} onClick={() => messageStore.sendMessage("Message")}>
Send Message
</button>
<h1 className={"text-xl"}>Messages</h1>
{allMessages.map(message => (
<div key={message.sent.toDate().toISOString()}>
{message.sent.toDate().toISOString()} - {message.text}
</div>
))}
</div>
);
}
Improving the Stores
Now that we’ve set up a reliable connection to SpacetimeDB, we can also improve our stores to take advantage of this updated flow. Specifically, we’ll add a setClient method to each store. This method is responsible for initializing the store with the active client and loading the initial data snapshot.
Once the initialization is complete, we set an internal isInitialized flag to true. While we're not using this flag just yet, it lays the groundwork for future improvements—like managing real-time updates more precisely (but that’s a topic for another day).
// /stores/messageStore.ts
class MessageStore {
private listeners: Set<() => void> = new Set();
private connection: DbConnection | null = null;
private cachedSnapshot: Message[] = [];
private serverSnapshot: Message[] = [];
private isInitialized = false;
// Only invoked by the hook
public setClient(client: DbConnection) {
if (!this.connection || this.connection !== client) {
this.connection = client;
this.subscribeToTables(client);
this.loadInitialSnapshot(client);
this.isInitialized = true;
}
}
private subscribeToTables(client: DbConnection) {
client.db.message.onInsert((ctx, row) => this.updateSnapshot());
client.db.message.onDelete((ctx, row) => this.updateSnapshot());
}
private loadInitialSnapshot(client: DbConnection) {
this.cachedSnapshot = Array.from(client.db.message.iter());
this.emitChange();
}
public subscribe(onStoreChange: () => void) {
this.listeners.add(onStoreChange);
return () => {
// Cleanup on unmount
this.listeners.delete(onStoreChange);
};
}
public getSnapshot() {
try {
return this.cachedSnapshot;
} catch (error) {
const isNotSSR = typeof window !== 'undefined';
if(isNotSSR) {
console.error('Unexpected error while obtaining snapshot:', error);
}
return this.serverSnapshot;
}
}
public getServerSnapshot() {
// Return the same reference to prevent unnecessary SSR re-renders
return this.serverSnapshot;
}
public sendMessage(newMessage: string){
if (this.connection) {
this.connection.reducers.sendMessage(newMessage);
}
}
private updateSnapshot() {
if (this.connection) {
this.cachedSnapshot = Array.from(this.connection.db.message.iter());
this.emitChange();
}
}
private emitChange() {
for (const listener of this.listeners) {
listener();
}
}
}
export const messageStore = new MessageStore();
Now let’s update the corresponding hook, useMessages, to initialize the store properly. We’ll do this by accessing the SpacetimeDB client via useSpacetimeDB, and then running setClient inside a useEffect.
This ensures the store is initialized once the connection is ready.
// / hooks/useMessages.ts
export function useMessages() {
const client = useSpacetimeDB();
// Ensure the store has access to the initialized client
// This useEffect will only run when client is guaranteed to be available useEffect(() => {
messageStore.setClient(client);
}, [client]);
const messages = useSyncExternalStore(
(callback) => messageStore.subscribe(callback),
() => messageStore.getSnapshot(),
() => messageStore.getServerSnapshot()
);
return messages;
}
This updated pattern ensures that:
- Stores don’t attempt to connect until the client is ready.
- Your components always have access to an initialized snapshot.
- We’ve separated concerns: connection logic lives in the provider, store logic lives in the store.
Finally, Updating the Connection Factory
Time for the cherry on top—the task we set out to solve in the first place: making getAuthToken asynchronous.
Let’s start with a simple placeholder that simulates fetching a token (e.g., from Auth0). In a real-world app, this would likely involve calling an API route or using a client SDK.
// /lib/spacetimedb/connectionFactory.ts
const getAuthToken = async () => {
// Simulate obtaining of token
await new Promise(resolve => setTimeout(resolve, 2000));
return "";
}
Since getAuthToken is now asynchronous, we also need to update buildDbConnection to be async as well.
// /lib/spacetimedb/connectionFactory.ts
const buildDbConnection = async () => {
console.log('[SpacetimeDB] Building connection...');
return DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('quickstart-chat')
.withToken(await getAuthToken())
.onConnect(onConnect)
.onDisconnect(onDisconnect)
.onConnectError(onConnectError)
.build();
}
The fun doesn’t stop there. We also need to update getDbConnection to handle the asynchronous client creation without creating multiple connections if it's called more than once during initialization.
To manage this, we use two variables:
-
singletonConnection: Stores the created client after it's ready. -
connectionPromise: Stores the in-progress connection promise, to be reused across calls while initialization is ongoing.
// /lib/spacetimedb/connectionFactory.ts
let singletonConnection: DbConnection | null = null;
let connectionPromise: Promise<DbConnection> | null = null;
export const getDbConnection = async (): Promise<DbConnection> => {
const isSSR = typeof window === 'undefined';
if (isSSR) {
throw new Error('Cannot use SpacetimeDB on the server.');
}
if (singletonConnection) {
return singletonConnection;
}
if (connectionPromise) {
return connectionPromise;
}
connectionPromise = buildDbConnection();
singletonConnection = await connectionPromise;
connectionPromise = null;
return singletonConnection;
};
Now we’re safe from double-initialization chaos and SSR nightmares. If getDbConnection is called before the connection is ready, it'll just await the same in-progress promise.
With the new singleton variables in place, we should also update disconnectDbConnection to properly reset everything and avoid memory leaks or lingering WebSocket connections.
// /lib/spacetimedb/connectionFactory.ts
export const disconnectDbConnection = () => {
if (singletonConnection) {
console.log('[SpacetimeDB] Disconnecting...');
singletonConnection.disconnect();
singletonConnection = null;
}
cleanupConnectionListener();
cleanupSubscriptionListener();
};
And that’s it—you’ve fully modernized your SpacetimeDB connection setup to support asynchronous authentication. The client is lazy-loaded, the stores initialize on demand, and you’re no longer stuck duct-taping state flags across hooks.
Top comments (0)