-
Notifications
You must be signed in to change notification settings - Fork 905
Description
useTable isReady state temporarily flips to false during reducer mutations, causing UI flashing
Description
When using the SpacetimeDB React SDK, the useTable hook returns a tuple [rows, isReady]. The isReady boolean correctly indicates when the initial subscription has been applied. However, when a client locally dispatches a reducer that mutates the subscribed table (e.g., an insert, update, or delete), the isReady state unexpectedly and transiently flips to false for a few milliseconds before returning to true.
Because standard React patterns rely on if (!isReady) return <Loading />, this momentary flip to false causes the entire rendered list/component to unmount and flash blank during every state update, creating a highly disruptive user experience.
Environment
- SpacetimeDB CLI Version:
2.0.4 - SpacetimeDB Server Version:
2.0.4 - SpacetimeDB SDK Version:
^2.0.4(npmspacetimedbpackage) - Runtime Environment: TypeScript/JavaScript Server Module
Steps to Reproduce
- Maintain an active server subscription via
useTable. - Map the data to the UI using a standard readiness check:
import { useTable, useReducer } from 'spacetimedb/react';
import { tables, reducers } from './module_bindings';
export function StarList() {
const [stars, isReady] = useTable(tables.star);
const createStar = useReducer(reducers.createStar);
// Standard loading check
if (!isReady) return <div>Loading stars...</div>;
return (
<div>
<button onClick={() => createStar({ message: "Test" })}>Add Star</button>
{stars.map(star => <div key={star.id}>{star.message}</div>)}
</div>
);
}- Click the "Add Star" button to dispatch the reducer.
- Observe the UI: the DOM briefly replaces the entire list with "Loading stars..." before immediately snapping back to the updated list.
Expected Behavior
The isReady boolean should represent whether the initial subscription has hydrated. Once it becomes true, a purely local mutation (like a reducer execution inserting a row) should not cause isReady to revert to false while the local cache handles the event.
The stars array successfully contains the local data during this time, so the isReady flag resetting is misleading and breaks UI persistence.
Actual Behavior
The isReady boolean is somehow tied to the internal snapshot recalculation or event loop of the connection.subscriptionBuilder(). During an ongoing transaction, it resets to false, causing React components respecting the isReady flag to unmount valid, existing rows data.
Root Cause Analysis
Inside useTable.ts, the hook relies on useSyncExternalStore combined with subscribeApplied state mapping.
When a transaction event triggers onInsert or onDelete, the SDK recalculates the computeSnapshot() and potentially causes React to evaluate subscribeApplied incorrectly during the strict-mode event batching, or setSubscribeApplied is inadvertently affected by the underlying SDK connection manager receiving an update event. This momentarily breaks the isReady output.
Current Workaround
Developers must ignore the isReady flag entirely for rendering guard clauses after the first load, and instead rely on the length of the data array. This prevents the UI from unmounting when isReady hallucinates a false state:
// Workaround: Do not use !isReady to block rendering
const [stars, isReady] = useTable(tables.star);
// Instead, rely on data length (which persists safely during the transient state)
if (stars.length === 0) {
// Only show loading if we also aren't ready
if (!isReady) return <div>Loading...</div>;
return <div>No stars found.</div>;
}
return <List data={stars} />Suggested Fix
The internal logic in useTable.ts must decouple the isReady boolean from individual row mutation updates. isReady should strictly track the onApplied lifecycle of the subscription (acting as a one-way latch that stays true once the subscription is active), rather than fluctuating when the snapshot cache updates due to a row insert/delete.