Skip to content

useDb selectors that return fresh object/array literals cause infinite re-renders #11

Description

@RobPruzan

Claude generated

Summary

useDb selectors that synthesize a new array/object shape inside the selector (rather than returning a reference owned by the replica) trigger Maximum update depth exceeded. The constraint is real, but it's invisible from the docs and the failure mode is just a stack trace deep inside react-dom, which makes it hard to diagnose.

Repro

A selector that projects into a new shape:

type Entry = { id: string; label: string }

function ShelfMenu() {
  const entries = useDb<Entry[]>(root => {
    const out: Entry[] = []
    for (const scope of Object.values(root.app.scopes)) {
      if (!scope.archived) continue
      out.push({ id: scope.id, label: scope.directory })
    }
    return out
  })
  return entries.map(e => <div key={e.id}>{e.label}</div>)
}

This loops on mount with:

Error: Maximum update depth exceeded. ...
  at forceStoreRerender (react-dom_client.js)
  at updateStoreInstance (react-dom_client.js)

Workaround

Return raw db slices and project in useMemo:

const scopesById = useDb(root => root.app.scopes)
const entries = useMemo(
  () =>
    Object.values(scopesById)
      .filter(s => s.archived)
      .map(s => ({ id: s.id, label: s.directory })),
  [scopesById],
)

What I expected

Either (a) it works (selectors are memoized / equality is structural), or (b) the framework surfaces a clear dev-mode warning instead of a generic React infinite-loop trace.

The docs at /core/state show useDb(root => root.app.todos) and useDb(root => root.app.count) — both happen to return refs the replica owns, so the constraint isn't visible from the examples. useDb(root => Object.values(root.app.scopes)) is used heavily inside our own app and works, which made me assume useDb did structural array equality. Building {...} literals inside the selector is what crosses the line.

Suggested improvements

  1. Document the equality contract in /core/state. Something like: "Selectors should return values owned by the database. Projecting into new object/array shapes inside the selector will cause re-render loops — use useMemo on top."
  2. Dev-mode warning when a selector returns a non-equal value across consecutive renders with no intervening db update, à la React's own getSnapshot should be cached warning for useSyncExternalStore. This is the change that would have saved the most debugging time.
  3. Optional: a useDb.shallow(...) / useShallow helper for the "I want to project into a new shape" case, mirroring zustand's API so users don't have to learn the raw-slice + useMemo idiom.

Environment

  • @zenbujs/core@0.0.25
  • React 19.2.6

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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