Lazy relationship resolution for large bidirectional entity graphs #3828
Replies: 2 comments
-
|
In ReactJS, rendering is controlled by hooks, not opaque reactive props. If you disagree with this philosophy, you can use vue. However, in my experience this leads to at best degenerate performance problems and at worse common app freezes, bug whack a mole, state jank, and timing problems. If a react component has no hooks, then it only renders if its parent renders. So that means that by tying this super large structure together, you are then forcing any changes from these descendants to trigger renders of their entire ancestry, forcing your whole component tree to run when any little thing changes. This is the 'broken' part, because without wiring that together, it will not re-render the entire tree. At that point, you might as well just enable reactivity by force rendering the whole react tree - rather than thinking about using reactivity mechanisms. It will have the same performance for component, but you loose the complexity and performance overhead of tracking reactivity. So yeah, you definitely want to be explicit about where you bind re-renders too. The only value I see in this is if for whatever reason you have a huge initial payload with everything at once to normalize. This is why I bothered with #3829. The only case you'll actually want true nested items is when they actually impact that top level render. The simplest example here is when you're sorting based on a nested entity in a top level table. In that case, mutating that entity does require re-rendering that top level table, so you definitely want those to be coupled. Other aggregates like groupby etc will all follow this pattern. In conclusion, the most performant and scalable way to build apps is by only denormalizing when you actually want to re-render that top level object. Otherwise, by making it disjoint, then only components that actually need to re-render will actually have to run when there is a mutation. |
Beta Was this translation helpful? Give feedback.
-
Recommended: Use
|
| Need | Solution |
|---|---|
| Long-term / new code | Lazy + useQuery() — best performance, correct re-render boundaries |
| Self-referential hierarchy (parent/children) | DepthLimited([Department], N) per field |
| Cross-type bidirectional (Dept ↔ Building) | CycleDetect + CycleTrackingEntity |
| Both on same entity | Combine as shown above |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Per @ntucker's suggestion in #3822, moving the lazy resolution discussion here.
Problem
With large bidirectional entity graphs (~20k entities, 4 types all referencing each other), eager denormalization traverses the entire graph. The depth limit from PR #3823 helps but:
parent/children)Dataset and schema for reference: https://github.com/jayseo5953/dataclient-repro-3822
Lazy proposal from #3822
@ntucker proposed a
lazy()schema wrapper +useQuery()for explicit resolution:The
LazySchemaimplementation is minimal — denormalize is a no-op, normalize delegates to the inner schema.Concern with this approach
The
lazy()marker lives on the entity class, making it a global decision. A relationship marked lazy returns raw IDs everywhere — every component that previously diddept.buildings[0].namenow gets a string instead of an Entity. This means:dept.buildings[0].nameanddept.parent.namehave different access patterns despite both being relationshipsuseQuerycall addedOur motivation was to keep the API invisible and consistent —
dept.buildings[0].nameshould always return a resolved Entity, regardless of whether it was resolved eagerly or lazily. The consumer shouldn't need to know or care about the resolution strategy.Our approach: transparent lazy resolution via Entity.denormalize override
We overrode
Entity.denormalize()in our base class to defer relationship resolution using ES5 getters. From the consumer's perspective, nothing changes —dept.buildings[0].namestill works, but the resolution happens on first property access instead of during the denormalization pass:Results
dept.buildings[0].nameworks exactly as before, no consumer changes neededThe problem: broken reactivity
unvisithas a dual role — it resolves entities AND registers dependencies for cache invalidation. The dependency list is finalized immediately afterdenormalize()returns:The dependency collection and entity resolution are coupled by design — both happen during the same
unvisittraversal. Our getter approach decoupled entity resolution (deferred) but inadvertently skipped dependency registration.The gap
dept.buildings[0].namelazy()+useQuery()useQuery([Building], dept.buildings)dept.buildings[0].nameThe ideal solution would combine the transparency of our approach (no consumer changes, consistent API) with correct reactivity. This would require decoupling dependency registration from entity resolution — registering dependencies eagerly during the denormalization pass (shallow walk of schema fields to record referenced PKs) while deferring actual Entity construction to property access:
This would keep the dependency list complete (reactivity works) while avoiding the deep recursive Entity construction that causes the crash.
Beta Was this translation helpful? Give feedback.
All reactions