-
Notifications
You must be signed in to change notification settings - Fork 873
Description
SpacetimeDB SDK version: 2.0.3 (TypeScript)
Summary
The useTable React hook's subscribe callback captures a stale computeSnapshot function that permanently has
subscribeApplied = false. After the initial subscription is applied and data loads correctly, any subsequent
onInsert, onDelete, or onUpdate event causes the hook to return isReady: false in its [rows, isReady] tuple,
even though the rows themselves are correctly updated.
Root Cause
In src/react/useTable.ts:
- computeSnapshot (line ~92) is a useCallback that captures subscribeApplied state and includes it in its
dependency array [connectionState, accessorName, querySql, subscribeApplied]. It returns [result,
subscribeApplied]. - subscribe (line ~132) is a useCallback with dependency array [connectionState, accessorName, querySql,
callbacks?.onDelete, callbacks?.onInsert, callbacks?.onUpdate]. Note: computeSnapshot is NOT in this dependency
array, but it is referenced inside the closure body by the onInsert, onDelete, and onUpdate handlers. - On mount, subscribeApplied is false, so computeSnapshot_v1 returns [rows, false]. The subscribe function
captures this computeSnapshot_v1. - When onApplied fires, setSubscribeApplied(true) triggers a re-render. A new computeSnapshot_v2 is created
that returns [rows, true]. The getSnapshot callback picks this up correctly and the component renders with
isReady: true. - However, subscribe is not recreated because none of its dependencies changed. The onInsert/onDelete/onUpdate
handlers inside it still reference computeSnapshot_v1 from step 3. - When a table event fires later, the handler runs:
lastSnapshotRef.current = computeSnapshot(); // calls computeSnapshot_v1 → [newRows, false]
onStoreChange(); - React calls getSnapshot(), which returns lastSnapshotRef.current — the tuple with false. The component
re-renders with isReady: false despite the subscription still being active.
Impact
Any consumer code that gates on isReady to process updates will silently skip every update after the initial
load:
const [rows, isReady] = useTable(tables.myTable);
useEffect(() => {
if (!isReady || !rows) return; // ← blocks ALL post-initial-load updates
syncData(rows);
}, [rows, isReady]);
The rows are correct — only the boolean is wrong. This makes it particularly hard to debug because the data
appears to arrive (logging the rows shows updated values) but downstream effects never fire.
Reproduction
- Use useTable(tables.someTable) and log the returned isReady value
- Wait for initial subscription to apply (observe isReady: true)
- Trigger a reducer that modifies a row in that table
- Observe that useTable returns the updated rows but isReady flips back to false
Suggested Fix
Either:
(A) Add computeSnapshot to the subscribe dependency array so it gets recreated when subscribeApplied changes:
const subscribe = useCallback(
(onStoreChange: () => void) => {
// ... handlers that call computeSnapshot() ...
},
[connectionState, accessorName, querySql, computeSnapshot, /* ...callbacks */]
// ^^^^^^^^^^^^^^^^ add this
);
(B) Use a ref for computeSnapshot so the handlers always call the latest version:
const computeSnapshotRef = useRef(computeSnapshot);
computeSnapshotRef.current = computeSnapshot;
// Then inside subscribe's handlers:
lastSnapshotRef.current = computeSnapshotRef.current();
Option (B) avoids re-subscribing listeners on every subscribeApplied change, which is likely preferable since
re-subscribing would briefly remove and re-add the onInsert/onDelete/onUpdate callbacks.
Current Workaround
Track whether the initial sync has happened and ignore isReady afterward:
const [rows, isReady] = useTable(tables.myTable);
const hasInitialSynced = useRef(false);
useEffect(() => {
if (!rows) return;
if (!hasInitialSynced.current && !isReady) return;
hasInitialSynced.current = true;
syncData(rows);
}, [rows, isReady]);