Skip to content

useTable hook returns isReady: false after update/insert/delete events due to stale computeSnapshot closure #4577

@AlmAnderson

Description

@AlmAnderson

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:

  1. 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].
  2. 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.
  3. On mount, subscribeApplied is false, so computeSnapshot_v1 returns [rows, false]. The subscribe function
    captures this computeSnapshot_v1.
  4. 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.
  5. 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.
  6. When a table event fires later, the handler runs:
    lastSnapshotRef.current = computeSnapshot(); // calls computeSnapshot_v1 → [newRows, false]
    onStoreChange();
  7. 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

  1. Use useTable(tables.someTable) and log the returned isReady value
  2. Wait for initial subscription to apply (observe isReady: true)
  3. Trigger a reducer that modifies a row in that table
  4. 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]);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions