Skip to content

useEntityList recreates all item accessors on every store change — unstable identity defeats React.memo in list consumers #64

Description

@MalaRuze

Summary

useEntityList returns brand-new EntityAccessor proxies for every item on every store version bump — including items whose data did not change. Accessor identity is therefore unstable across renders, which defeats React.memo (and any identity-keyed caching) in list consumers: editing one item's field re-renders every sibling's subtree. In a page-builder canvas with ~100 blocks this makes every keystroke / delete reconcile the whole canvas.

Environment

Reproduction

const Row = React.memo(function Row({ item }) {
	renderCounts[item.id] = (renderCounts[item.id] ?? 0) + 1
	return <span>{item.name.value}</span>
})

function List() {
	const authors = useEntityList(authorDef, {}, a => a.id().name())
	if (authors.$status !== 'ready') return null
	return (
		<>
			{authors.items.map(item => <Row key={item.id} item={item} />)}
			<button onClick={() => authors.items[0]!.name.setValue('Renamed')}>rename</button>
		</>
	)
}

Click the button (changes only author-1): renderCounts['author-2'] increments too — the memo never bails because item is a new proxy object each render. The pushed test also asserts the root cause directly: authors.items[i] for an unchanged entity is not reference-equal across renders.

Expected behavior

Accessors for unchanged entities keep referential identity across renders — EntityHandle's own doc states "Stable identity (same instance across renders when id doesn't change)" (packages/bindx/src/handles/EntityHandle.ts). HasManyListHandle already implements this contract via its itemHandleCacheProxy; useEntityList should honor it too so React.memo list children can bail out.

Actual behavior

Both tests on the branch fail:

  • untouched[untouched.length - 1] is not reference-equal to untouched[0] (new proxy per render for an unchanged entity), and
  • the memoized sibling row re-renders after an unrelated setValue (Expected: 1, Received: 2).

No error is thrown — the effect is silent whole-list re-rendering.

Suspected root cause

packages/bindx-react/src/hooks/useEntityList.ts, getSnapshot (~lines 314–347): the list cache is keyed on store.getVersion() — the global store version — so any store change invalidates it, and the rebuild calls EntityHandle.create(...) per item, producing a fresh handle + proxy each time:

const storeVersion = store.getVersion()
if (cache && ... && cache.storeVersion === storeVersion ...) return cache.result
...
const items = state.items.map((item) => {
	return EntityHandle.create<object>(item.id, entityType, store, dispatcher, ...)
})

A fresh root handle also cascades: its relationHandleCache starts empty, so every nested HasOne/HasMany handle — and their per-item proxy caches — are recreated as well. Downstream consumers (e.g. a BlockRepeater fed from entity.blocks.items) therefore see new item proxies for the entire list on every store change.

Suggested fix

Cache item handles per (entityType, entityId) in a Map held by the hook (or better, a store-level handle registry shared by all accessor producers), and reuse the cached proxy when rebuilding the snapshot — only create handles for ids not yet seen. Since EntityHandle is a stateless view over the store, reusing an instance is safe; the snapshot rebuild then preserves identity for unchanged ids while still reflecting order/membership changes through the array itself.

Workaround shipped downstream

We applied a temporary workaround in our project, marked
TODO [BindX] (<this-issue-url>): <description>. The workaround replaces React.memo's
identity comparison with an entity-id comparison for canvas block previews (an older
proxy reads identical live store data, so rendering through it is equivalent); we will
remove it once this issue is resolved.

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