feat(zero-react): add select option to useQuery for selective re-renders#5455
feat(zero-react): add select option to useQuery for selective re-renders#5455Karavil wants to merge 1 commit intorocicorp:mainfrom
Conversation
|
Someone is attempting to deploy a commit to the Rocicorp Team on Vercel. A member of the Team first needs to authorize it. |
b06d88c to
932ad5f
Compare
Adds a `select` option to useQuery that transforms query results and
uses deep equality to prevent re-renders when the selected value
hasn't changed. This encourages top-down querying patterns where
components subscribe to derived values rather than entire result sets.
Example:
```typescript
const studentIds = useQuery(
z.query.school({id: schoolId}),
{select: (school) => school?.students.map(s => s.id) ?? []}
);
// Only re-renders when the ID list changes
```
Key behaviors:
- select function receives HumanReadable<TReturn> (works with .one() and arrays)
- Deep equality comparison only on selected value, not raw data
- Status/details changes always trigger re-render (even if selected value same)
- Array instances change when content changes (works as useEffect deps)
- TypeScript generics properly infer TSelected from select function
Includes 4 new tests covering:
- select with .one() queries
- select with undefined during loading
- status changes cause re-render
- no select maintains reference equality
932ad5f to
a709507
Compare
|
Did you consider using You can use deepEquals to decide whether the props have changed. |
|
Alternately if we want to make a change in this area should just fix the underling bug: |
Using React.memo doesn't stop re-renders unless you perfectly structure your components using a parent |
I think it'd be great to do both! More performance! |
// The crux: React.memo can't block re-renders triggered by hooks inside the component
function Row({ id }) {
const [students] = useQuery(allStudentsQuery); // ← re-render triggered here
const student = useMemo(
() => students.find(s => s.id === id),
[students, id]
);
// useMemo memoizes the find() result, but we've already re-rendered:
// JSX diffing, effects, and child reconciliation all run
return <tr>{student?.name}</tr>;
}
const MemoizedRow = React.memo(Row); // does nothing: re-render comes from inside, not propsThe "perfect structure" requires lifting the query to a parent and passing deeply-compared props: function Roster() {
const [students] = useQuery(allStudentsQuery); // parent re-renders on every change
return students.map(s => <MemoizedRow key={s.id} student={s} />);
}
const MemoizedRow = React.memo(Row, (a, b) => deepEquals(a.student, b.student));This works, but fragments data requirements across components. With function StudentIdList({ classId }) {
const [studentIds] = useQuery(classQuery(classId), {
select: (cls) => cls?.students.map(s => s.id),
});
// Only re-renders when the list of IDs changes:
// - Class name changes? No re-render
// - Student name changes? No re-render
// - Student added/removed? Re-render
return <ul>{studentIds?.map(id => <li key={id}>{id}</li>)}</ul>;
}The deep equality check on the selected value means you only pay for what you use. |
Looking at that PR (#3688): it makes the // Arrays still get new refs on any change
const newView = view.toSpliced(pos, 1);So
This means const MemoizedRow = React.memo(({ student }) => <tr>...</tr>);
// Only rows that changed re-render (row refs are stable)But it doesn't help when you're deriving data from the query. If you just need a list of student IDs from a class: const [cls] = useQuery(classQuery);
const studentIds = useMemo(() => cls?.students.map(s => s.id), [cls]);
// Re-renders when ANY class or student data changes, even though
// we only care about the list of IDsWith const [studentIds] = useQuery(classQuery, {
select: (cls) => cls?.students.map(s => s.id),
});
// Only re-renders when the actual list of IDs changesBoth PRs complement each other: #3688 makes prop-drilling + memo viable for row-level stability, |
|
Thanks for this submission. I'm not sure about this from a feature POV and want to think about it some more. I think it is possible to implement something like this in userland? So you're not blocked on us to do it. |
Fair! I tried doing this in userland and it was quite buggy as I had to patch Zero to export some internal types to make it work as I couldn't use |
Happy to make this an |
|
@Karavil Thanks for your work here. I've read up on the situation and it seems like a valuable addition. Our main concern at the moment is more about how this fits into our API.
import {queries} from '...';
const q = queries.getAllStudents().select('id', 'name');
const [students] = useQuery(q);The current thinking is that we should export the needed types and helper functions to allow something like this to be doable in user land. Would you be interested in helping me understand what you need to achieve this? |
) ## Immutable `applyChange` with O(N + K) Batching Makes `applyChange` return new objects instead of mutating in place. Unchanged rows keep their object reference, changed rows get new references. This lets React.memo and Solid reactivity actually work. Right now Zero deep clones every snapshot because `applyChange` mutates in place. Every row gets a new reference on every sync, even if nothing changed. ``` Current behavior (mutable + deepClone): DB: UPDATE row A Consumer receives snapshot ┌──────────────┐ ┌──────────────────────────┐ │ A = "new" │ │ A' ← new ref (changed) │ │ B = "same" │ ────► │ B' ← new ref (unchanged) │ ← React.memo fails │ C = "same" │ │ C' ← new ref (unchanged) │ ← everything re-renders └──────────────┘ └──────────────────────────┘ ``` With immutable `applyChange`, unchanged rows keep their reference: ``` New behavior (immutable): DB: UPDATE row A Consumer receives snapshot ┌──────────────┐ ┌──────────────────────────┐ │ A = "new" │ │ A' ← new ref (changed) │ ← re-render │ B = "same" │ ────► │ B ← same ref │ ← memo skips │ C = "same" │ │ C ← same ref │ ← memo skips └──────────────┘ └──────────────────────────┘ ``` ## Performance Started with a naive immutable implementation. @tantaman pointed out it was O(K × N) for K changes to an N-item array: ``` Naive immutable (O(K × N)): push(change₁) ──► [...array] ─┐ push(change₂) ──► [...array] │ K rebuilds push(change₃) ──► [...array] │ of N items ... │ push(changeₖ) ──► [...array] ─┘ ``` Ran benchmarks, it was ~4x slower at scale. Still under 0.1ms but felt like we could do better. Tried batching but hit a blocker: tests expected `.data` to reflect changes immediately after `push()`. Solution: auto-flush in the `.data` getter. ``` Batched immutable (O(N + K)): push(change₁) ──► buffer ─┐ push(change₂) ──► buffer │ O(K) to buffer push(change₃) ──► buffer │ ... │ push(changeₖ) ──► buffer ─┘ │ ▼ flush() ─────► sorted merge ──► one new array │ O(N + K log K) ``` The `.data` getter auto-flushes if there are pending changes, so existing code just works. ``` Benchmarks (time in ms): N=2000, K=200 ───────────────────────────────────────────────────────── Mutable O(K) │████████████████████████ 0.023ms Immutable O(K×N) │████████████████████████████████████████████████████████ 0.109ms Batched O(N+K) │███████████████ 0.015ms ◄── faster than mutable! ───────────────────────────────────────────────────────── ``` At larger scales batched beats mutable because we do not have to copy the objects over and over ## Changes ``` applyChange() ├── returns Entry instead of void ├── uses toSpliced()/with() for immutable array ops └── uses spread for immutable object ops ArrayView ├── push() buffers changes ├── flush() applies in one sorted merge pass └── .data getter auto-flushes for backwards compat Consumers ├── React: removes deepClone └── Solid: uses produce with in-place mutation ``` ## Manual Testing: React (zbugs) To verify React.memo actually works with stable references, I modified zbugs' list page: 1. Extracted `IssueRow` outside the component and wrapped with `React.memo` 2. Added custom comparator (default shallow compare fails because `style` is new object each render) 3. Added render count tracking to each row ```tsx const areIssueRowPropsEqual = (prev, next) => { if (prev.issue !== next.issue) return false; // reference check if (prev.style?.transform !== next.style?.transform) // value check return false; // ... other props return true; }; const IssueRow = memo(function IssueRow({ issue, style, ... }) { // render count tracking for testing }, areIssueRowPropsEqual); ``` Then ran the app and updated rows directly via psql while watching console: ``` ┌───────────────────────────────────────────────────────────────────────┐ │ Test 1: Update title only (no sort position change) │ ├───────────────────────────────────────────────────────────────────────┤ │ │ │ psql> UPDATE issue SET title = 'TITLE ONLY UPDATE' │ │ WHERE id = '_6cIk1pH24n6nmJ5sI6Ar'; │ │ │ │ Console output after Zero sync: │ │ ┌───────────────────────────────────────────────────────────────┐ │ │ │ [render #3] IssueRow: _6cIk1pH - "TITLE ONLY UPDATE" │ │ │ │ (sameRef: false) │ │ │ └───────────────────────────────────────────────────────────────┘ │ │ │ │ Result: ONLY the updated row re-rendered. Other 99 rows skipped. │ │ │ └───────────────────────────────────────────────────────────────────────┘ ┌───────────────────────────────────────────────────────────────────────┐ │ Test 2: Update title + modified (causes sort position change) │ ├───────────────────────────────────────────────────────────────────────┤ │ │ │ psql> UPDATE issue SET title = 'POSITION CHANGE', │ │ modified = 1769210037000 │ │ WHERE id = 'dSHOxE86nkbGafajdd1ew'; │ │ │ │ Console output after Zero sync: │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ [render #3] IssueRow: dSHOxE86 - "POSITION CHANGE" │ │ │ │ [render #3] IssueRow: oGP3NonM - "Some other issue" │ │ │ └────────────────────────────────────────────────────────────────┘ │ │ │ │ Result: Updated row + displaced row re-rendered. Expected behavior │ │ since style.transform changed for rows that moved. │ │ │ └───────────────────────────────────────────────────────────────────────┘ ``` Key insight: Zero maintains stable object references for unchanged issues. The `sameRef: true/false` logging confirmed this. When only the title changes, that row gets a new reference, but all other rows keep their original reference. ## Manual Testing: Solid (zbugs-lite-solid) (this is no longer part of the PR) Created a minimal Solid app that imports Zero schema/queries from zbugs. Added render count badges to each row. ``` ┌────────────────────────────────────────────────────────────────────────┐ │ Test: Update one issue while watching render counts │ ├────────────────────────────────────────────────────────────────────────┤ │ │ │ psql> UPDATE issue SET title = 'MODIFIED BY PSQL' │ │ WHERE id = 'HdpMkgbH...'; │ │ │ │ Before: After: │ │ ┌──────────────────────────┐ ┌──────────────────────────┐ │ │ │ [1] Leaking listeners... │ │ [1] MODIFIED BY PSQL │ ← +0 │ │ │ [1] Sort and remove... │ │ [1] Sort and remove... │ │ │ │ [1] UnknownError: Int... │ │ [1] UnknownError: Int... │ │ │ │ [1] Fix: race condition │ │ [1] Fix: race condition │ │ │ │ [1] Add support for... │ │ [1] Add support for... │ │ │ └──────────────────────────┘ └──────────────────────────┘ │ │ │ │ Items keep their reference so Solid never rerenders. │ │ All other rows stayed at 1. │ │ │ └────────────────────────────────────────────────────────────────────────┘ ``` Solid's fine-grained reactivity handles the in-place updates efficiently via produce. No special memo config needed. ## Related - Follows up on #5455 - Revives #3688 (original immutable attempt) - Fixes zero/3036 --------- Co-authored-by: Alp <alp@Alps-MacBook-Pro.local> Co-authored-by: Alp <alp@Alps-MBP.localdomain> Co-authored-by: Alp <alp@mac.mynetworksettings.com> Co-authored-by: Matt Wonlaw <matt.wonlaw@gmail.com> Co-authored-by: Erik Arvidsson <arv@roci.dev> Co-authored-by: Erik Arvidsson <erik.arvidsson@gmail.com>
Summary
Adds
selectoption touseQuery. Transforms the query result and uses deep equality to determine re-renders.Why
Rendering a list of 30 students today:
None of these are great. A gives you re-render problems, B gives you N views, C gives you maintenance burden.
You might think useMemo solves A:
But
studentsis a new array reference on every update, so useMemo recalculates every time. The component still re-renders when any student changes. useMemo just memoizes the find, not the render.In a perfect world, Zero's view syncer would recognize that 30
studentQuery(id)calls can be combined into oneallStudentsQueryand handle it server-side. But Zero can't smush ASTs together like that today. So this is a client-side compromise.What
selectruns insideuseSyncExternalStore'sgetSnapshot. When the query updates, we deep-compare the new selected value against the previous one. Same value? Return the cached snapshot. React never sees a change, no re-render. Different value? New snapshot, React re-renders.Status changes (unknown → complete → error) always trigger re-renders regardless of the selected value. TypeScript infers the return type from your select function.
Still loads all students upfront, so not a silver bullet. But one view on the server, surgical re-renders on the client, and data requirements stay colocated with the component.
Testing
48 tests. Covers primitives, objects, arrays,
.one(), loading states, status transitions, type inference, backward compat.