Skip to content

refactor(bindx-react): stable EntityHandle identity (fine-grained reactivity)#56

Open
matej21 wants to merge 1 commit into
fix/drop-handle-dispose-lifecyclefrom
refactor/stable-handle-identity
Open

refactor(bindx-react): stable EntityHandle identity (fine-grained reactivity)#56
matej21 wants to merge 1 commit into
fix/drop-handle-dispose-lifecyclefrom
refactor/stable-handle-identity

Conversation

@matej21

@matej21 matej21 commented Jun 25, 2026

Copy link
Copy Markdown
Member

Problem

EntityHandle is a stateless live view over the store, but useEntity and <Entity> recreated it on every snapshot/version change — snapshot/version were fed into the handle's useMemo deps. The only reason was to hand React.memo-wrapped children a fresh reference so they'd re-render (see the old EntityHandleRenderer doc comment: "children may be wrapped in React.memo and need a new handle reference to trigger re-renders").

That reference-churn worked against the store's whole point — fine-grained reactivity:

  • Any field edit threw away the entire handle subtree and its fieldHandleCache/relationHandleCache, rebuilding them lazily. The "stable identity" promised in BaseHandle's docstring held only within one data version.
  • It re-rendered the whole entity subtree on any change, even leaves that don't read the changed field — so <Field>/<HasOne>/<HasMany>, which already subscribe to the store themselves, were force-re-rendered redundantly.
  • It churned useEffect([handle]) / useMemo([handle]) deps in consumer code (the same identity-churn family as fix(bindx-react): stabilize useEntityList selection identity to prevent refetch loop #39's refetch loop).
  • It created the "superseded handle" concept that the dispose lifecycle (removed in the base PR) existed to clean up.

Change

The blessed leaf components already re-render via their own subscription (useField/useAccessoruseSyncExternalStore), independent of the handle reference. So the recreation is redundant.

  • Drop snapshot/version from the handle useMemo deps in useEntity and EntityHandleRendererthe EntityHandle keeps a stable identity across data changes.
  • The host still subscribes and re-renders, so inline accessor.value reads in children stay fresh (ergonomics preserved). Memoized leaves now re-render only through their own subscription — a field edit re-renders just the leaves that read it.
  • Field/relation handle caches now live for the entity's mount lifetime instead of being rebuilt every change.

The one behavioral change: a React.memo-wrapped component that reads accessor.value inline without subscribing (i.e. without <Field>/useField) no longer auto-updates from a parent re-render — it must subscribe. That's the intended, teachable contract, and the primitives for it already exist and are the recommended path.

Tests

tests/react/jsx/stableHandleIdentity.test.tsx:

  • the entity/field accessor keeps a stable identity across a data-driven re-render — both the <Entity> and direct useEntity paths;
  • a memoized child holding the accessor does not re-render on an unrelated data change, while <Field> stays live (fine-grained reactivity);
  • the host still re-renders so inline reads stay fresh.

All three fail on the pre-change behavior (verified by temporarily restoring the deps) and pass after. Full suite: 1605 pass; the only failures are the playground browser tests (no live playground in this env — CI's test-browser job covers them) and one pre-existing bindx-form failure unrelated to this change.

Stacked on #55 (drop dispose). This is the architectural follow-up: #55 removes the dead dispose machinery, this removes the handle recreation that made "superseded handles" a thing in the first place. Retarget to main once #55 merges.

🤖 Generated with Claude Code

…a changes

EntityHandle is a stateless live view over the store, yet `useEntity` and
`<Entity>` recreated it on every snapshot/version change (by feeding
`snapshot`/`version` into the handle's useMemo deps) purely to hand memoized
children a fresh reference. That defeated the store's fine-grained reactivity:
any field change threw away the whole handle subtree and its caches, re-rendered
the entire entity subtree, churned `useEffect([handle])` deps, and created the
"superseded handle" concept the (now-removed) dispose lifecycle existed to clean
up.

The blessed leaf components (`<Field>`, `<HasOne>`, `<HasMany>`) already
subscribe to the store themselves (useField/useAccessor -> useSyncExternalStore),
so they re-render on data changes without needing a changing handle reference.
Drop `snapshot`/`version` from the handle memo deps so the handle keeps a stable
identity. The host still subscribes and re-renders (keeping inline `.value` reads
in `children` fresh); memoized leaves now re-render only via their own
subscription, so a field edit re-renders just the leaves that read it.

Adds regression tests: stable accessor identity across a data-driven re-render
(both `<Entity>` and `useEntity` paths), a memoized child holding the accessor
no longer re-rendering on an unrelated data change, and `<Field>` staying live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKx7JbV7Z7EVXofqRgwop
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant