Skip to content

feat(zero-react): add select option to useQuery for selective re-renders#5455

Draft
Karavil wants to merge 1 commit intorocicorp:mainfrom
Karavil:feat/use-query-select
Draft

feat(zero-react): add select option to useQuery for selective re-renders#5455
Karavil wants to merge 1 commit intorocicorp:mainfrom
Karavil:feat/use-query-select

Conversation

@Karavil
Copy link
Contributor

@Karavil Karavil commented Jan 22, 2026

Summary

Adds select option to useQuery. Transforms the query result and uses deep equality to determine re-renders.

const [student] = useQuery(studentsQuery, {
  select: (students) => students.find(s => s.id === studentId),
});
// re-renders only when this student changes, not when other students change

Why

Rendering a list of 30 students today:

// Option A: prop drill, all 30 rows re-render when any student changes
function Roster() {
  const [students] = useQuery(allStudentsQuery);
  return students.map(s => <Row student={s} />);
}

// Option B: query per row, 30 views on the server
function Row({ id }) {
  const [student] = useQuery(studentQuery(id));
  return <tr>...</tr>;
}

// Option C: prefetch in layout.tsx, use in Row.tsx, keep them in sync manually

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:

function Row({ id }) {
  const [students] = useQuery(allStudentsQuery);
  const student = useMemo(() => students.find(s => s.id === id), [students, id]);
  return <tr>...</tr>;
}

But students is 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 one allStudentsQuery and handle it server-side. But Zero can't smush ASTs together like that today. So this is a client-side compromise.

What

function Row({ id }) {
  const [student] = useQuery(allStudentsQuery, {
    select: (students) => students.find(s => s.id === id),
  });
  return <tr>...</tr>;
}

select runs inside useSyncExternalStore's getSnapshot. 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

npm install
npm test -- packages/zero-react/src/use-query.test.tsx

48 tests. Covers primitives, objects, arrays, .one(), loading states, status transitions, type inference, backward compat.

@vercel
Copy link

vercel bot commented Jan 22, 2026

Someone is attempting to deploy a commit to the Rocicorp Team on Vercel.

A member of the Team first needs to authorize it.

@Karavil Karavil force-pushed the feat/use-query-select branch from b06d88c to 932ad5f Compare January 22, 2026 01:51
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
@Karavil Karavil force-pushed the feat/use-query-select branch from 932ad5f to a709507 Compare January 22, 2026 01:55
@aboodman
Copy link
Contributor

Did you consider using React.memo to prevent the child row components from re-rendering? this is what we have done in the past.

You can use deepEquals to decide whether the props have changed.

@aboodman
Copy link
Contributor

Alternately if we want to make a change in this area should just fix the underling bug:

https://bugs.rocicorp.dev/p/zero/issue/3036

@Karavil
Copy link
Contributor Author

Karavil commented Jan 22, 2026

Did you consider using React.memo to prevent the child row components from re-rendering? this is what we have done in the past.

You can use deepEquals to decide whether the props have changed.

Using React.memo doesn't stop re-renders unless you perfectly structure your components using a parent useMemo + a component memo. It's a bit of a headache. And the parent still re-renders if they use this hook, even though useMemo technically memoizes the parsed section.

@Karavil
Copy link
Contributor Author

Karavil commented Jan 22, 2026

Alternately if we want to make a change in this area should just fix the underling bug:

https://bugs.rocicorp.dev/p/zero/issue/3036

I think it'd be great to do both! More performance!

@Karavil
Copy link
Contributor Author

Karavil commented Jan 22, 2026

Did you consider using React.memo to prevent the child row components from re-rendering? this is what we have done in the past.

You can use deepEquals to decide whether the props have changed.

// 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 props

The "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 select, data stays colocated and re-renders are blocked at the source:

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.

@Karavil
Copy link
Contributor Author

Karavil commented Jan 22, 2026

Alternately if we want to make a change in this area should just fix the underlying bug:

https://bugs.rocicorp.dev/p/zero/issue/3036

Looking at that PR (#3688): it makes the React.memo approach more viable by giving individual rows stable references, but it doesn't change array reference behavior.

// Arrays still get new refs on any change
const newView = view.toSpliced(pos, 1);

So useEffect with [students] as a dependency still fires when any student changes. The granularity is at the row level, not the array level:

  • students array: new reference on any change
  • students[0]: same reference if that row didn't change

This means React.memo with shallow comparison works after that PR:

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 IDs

With select:

const [studentIds] = useQuery(classQuery, {
  select: (cls) => cls?.students.map(s => s.id),
});
// Only re-renders when the actual list of IDs changes

Both PRs complement each other: #3688 makes prop-drilling + memo viable for row-level stability, select makes derived/projected data viable by blocking re-renders at the source.

@aboodman
Copy link
Contributor

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.

@Karavil
Copy link
Contributor Author

Karavil commented Jan 23, 2026

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 useQuery directly (you need to hook into the Zero client itself + re-implement useQuery the same way I did here if you want to prevent memo + useMemo infecting your whole codebase). FWIW this would drastically improve our table and grid performance as we recently refactored ~600 individual queries to use a top-down query (which then re-renders the whole grid).

@Karavil
Copy link
Contributor Author

Karavil commented Jan 23, 2026

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.

Happy to make this an experimental_useSelectQuery too by the way if y'all are not sure about supporting this going forward.

@arv
Copy link
Contributor

arv commented Jan 27, 2026

@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.

  • Could this be done with select on Query

  • We used to be able to refine "custom" queries with local refinements:

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?

github-merge-queue bot pushed a commit that referenced this pull request Feb 12, 2026
)

## 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>
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.

3 participants