DEV Community

Cover image for Managing Async Connections in Next.js with SpacetimeDB
Theodor Risgaer
Theodor Risgaer

Posted on

Managing Async Connections in Next.js with SpacetimeDB

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.ts
  • spacetimeDBHooks.ts
  • spacetimeDBProvider.tsx
  • dataPreloader.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 ...
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  • client to hold the initialized connection
  • isLoading to track the loading status
  • error to 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>  
  );  
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Tracks a local isMounted flag to prevent setting state after unmounting.
  2. Calls getDbConnection asynchronously.
  3. Subscribe to connection events to detect changes in connection status.
  4. Subscribes to subscription events to detect when initial data is loaded.
  5. 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();  
  };  
}, []);
Enter fullscreen mode Exit fullscreen mode

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}</>;  
};
Enter fullscreen mode Exit fullscreen mode

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();  
}
Enter fullscreen mode Exit fullscreen mode

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();  
}
Enter fullscreen mode Exit fullscreen mode

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);  
};
Enter fullscreen mode Exit fullscreen mode

Removing Old Connection State Code

Now that connection state is handled by the provider, we can clean up legacy logic:

  1. Delete useConnection and connectionStore.
  2. 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>  
  );  
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;  
}
Enter fullscreen mode Exit fullscreen mode

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 "";
}
Enter fullscreen mode Exit fullscreen mode

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();  
}
Enter fullscreen mode Exit fullscreen mode

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;  
};
Enter fullscreen mode Exit fullscreen mode

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();  
};
Enter fullscreen mode Exit fullscreen mode

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)