Skip to content

Stale UI when React Compiler is enabled peek()/get() keep root identity across in-place child mutations #653

@VirtuozTM

Description

@VirtuozTM

Summary

When React Compiler is enabled, downstream pure functions that take a Legend-State observable's value as an argument can be silently memoized against an unchanged reference, returning stale results even though the observable was correctly updated. The bug is invisible from a console.log inside the React component (which still fires) but visible from a console.log inside the pure function (which never fires after the first call). The UI shows yesterday's data until the screen unmounts and remounts.

Versions

  • @legendapp/state v3 (/sync-plugins/supabase + /sync-plugins/crud)
  • React 19 with react-compiler-runtime
  • react-native 0.81 / Expo SDK 53
  • transform.reactCompiler=true in the Metro bundler

Repro

Setup: an observable backed by syncedSupabase, mutated client-side via the toggle pattern.

export const favorites$ = observable(
  customSynced({ collection: 'favorites', actions: ['read', 'create', 'delete'], ... }),
);

favorites$[newId].set(entry); 
favorites$[existingId].delete(); 

Consumer:

function useFavoritesList() {
  const favorites = useSyncExternalStore(
    (cb) => favorites$.onChange(cb),
    () => favorites$.get(),   
  );
  // ↓ React Compiler memoizes this call by argument identity
  return joinFavoritesWithContent(favorites ?? {}, articles ?? {}, ...);
}

Steps:

  1. Render useFavoritesList with 5 entries. UI shows 5.
  2. From another screen, call favorites$[oneId].delete(). The observable internally drops the key — but the root object reference is unchanged (Legend-State mutates in place; this is an intentional perf optimization).
  3. The screen re-renders (the console.log at the top of useFavoritesList fires, showing Object.keys(favorites).length === 4).
  4. But joinFavoritesWithContent is not called. A console.log at the top of that function never fires. React Compiler's cache returns the previous result (5 items).
  5. UI shows 5 entries. Reloading the screen "fixes" it.

Root cause

Two intentional behaviors collide:

  1. Legend-State intentionally mutates the root object in place to avoid copying large maps on every child set. peek() and get() return that same reference.
  2. React Compiler auto-memoizes function calls by argument reference identity. With Object.is(prev, next) returning true, the cached call result is reused.

Neither side is buggy in isolation; the pair is.

Why useSyncExternalStore alone doesn't help

useSyncExternalStore has its own check that runs before any re-render: if getSnapshot() returns the same reference, the subscriber is not notified at all. So () => obs$.get() as the snapshot getter also fails on in-place mutations (separate React-level bug).

Workaround: maintain a version counter in the subscribe closure, return it from getSnapshot. That fixes the React-level notify, but Compiler-level memoization remains stale unless the observable's exposed value identity also changes.

Suggested fixes (in order of preference)

  1. Document the pattern. Add a "React Compiler" section to the docs explaining that direct obs$.get() / peek() returns must not be passed to compiler-visible pure functions, and provide an official wrapper (see useComputed doesn't update on deps change #2).

  2. Ship an official useObservableValue (or similar) hook that internally:

    • Subscribes via obs$.onChange
    • Maintains a version counter
    • Returns Object.assign({}, peek()) (or equivalent shallow clone) inside useMemo([obs$, version]) so each notified change yields a new identity
  3. Make use$ work in mid-tree with observer()-wrapped ancestors. Today use$ branches on the global reactGlobals.inObserver flag and shifts hook order when the flag flips between renders — which forces some projects to bypass use$ entirely. Making use$ unconditional in its hook calls (so the hook count is stable regardless of inObserver) would let projects use the official API and inherit any future Compiler-aware fixes for free.

Workaround we shipped

export function useObservableValue<T>(obs$: Observable<T>): T | undefined {
  const store = useMemo(() => {
    let version = 0;
    const listeners = new Set<() => void>();
    let unsubscribe: (() => void) | null = null;
    const subscribe = (cb: () => void) => {
      if (listeners.size === 0) {
        unsubscribe = obs$.onChange(() => {
          version++;
          for (const l of listeners) l();
        });
      }
      listeners.add(cb);
      return () => {
        listeners.delete(cb);
        if (listeners.size === 0) {
          unsubscribe?.();
          unsubscribe = null;
        }
      };
    };
    return { subscribe, getVersion: () => version };
  }, [obs$]);

  const version = useSyncExternalStore(
    store.subscribe,
    store.getVersion,
    store.getVersion,
  );

  return useMemo(() => {
    const v = obs$.peek();
    if (v && typeof v === 'object') {
      return { ...(v as object) } as T;
    }
    return v as T | undefined;
  }, [obs$, version]);
}

Trade-off: a shallow clone is allocated on every change. Negligible for record-shaped observables with up to a few hundred entries; would need a different strategy for very large maps.

Why this matters now

React Compiler is moving toward stable / default-on. As more apps adopt it, this trap will surface in many code bases and from the user's perspective it manifests as "delete doesn't work" or "data is wrong but reload fixes it," which is hard to root-cause without log-instrumenting the suspected pure function.

Happy to PR a docs note or a useObservableValue helper if there's interest.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions