Paginated Query Design
Lets say you have a table of messages, and you have two views in your app: Inbox and Archived.
What is the best way to design pagination queries for these two views that optimizes caching and is responsive to messages ingressing and egressing these states?
1) two separate query functions with fixed filters
2) one query with an arg to denote which messages we are currently interested in
3) doesnt matter
4) something else entirely
19 Replies
Another consideration for the design is to make optimistic responses as clean as possible.
One of the reasons I raise this question is
usePaginatedQuery
and its first arg on the backend is a bit black-boxy to me.
I understand what its doing at a high level and the examples of basic use cases makes sense, but as I delve into more complex use cases, I'm left unsure how to appropriately optimize.
Additionally, how the internal management of cursors works and how best to deal with the result sets changing as messages get pushed and pulled to and fro an archived/unarchived status.
FWIW there will only be one query in view at a given time, archived or unarchived (inbox).Good questions!
I think the short answer is "3) doesnt matter". Pagination should work correct if you use separate query functions or a single query function with an arg.
usePaginatedQuery
considers the query to be different if the names or arguments change. So if the React component rerenders with a different query or argument, the hook will unsubscribe from all previous pages and subscribe to the first one with the new name/args
Additionally, how the internal management of cursors works and how best to deal with the result sets changing as messages get pushed and pulled to and fro an archived/unarchived status.Pagination is mostly standard cursor based pagination. Each query function result includes the page of results and a cursor.
usePaginatedQuery
will fetch the next page of results passing in the cursor from the last page.
The tricky bit is making this correctly reactively. We do this by having each page also remember the end cursor that it computed on the first run. If data changes and the query function recomputes it will always sync the items up until its end cursor (even if that's a different number of items than you initially requested!). This ensures that the pages never miss items or overlap as the data changes.Another consideration for the design is to make optimistic responses as clean as possible.Optimistically updating paginated queries is a little tricky. Normally you could use
localStore.setQuery
to change the value of the query optimistically. The problem with paginated queries is that the arguments include cursors which you don't know at the mutation site.
Take a look at https://docs.convex.dev/api/modules/react#optimisticallyupdatevalueinpaginatedquery. This update will look in the store for all pages of a paginated query and update the values in all of them. It currently only supports changing the values of items in a paginated query, but you could take a similar approach to add or delete itemsModule: react | Convex Developer Hub
Tools to integrate Convex into React applications.
Let me know if you want to hop on a call to talk through this stuff. It can be pretty gnarly
If one wanted to keep the extra pages around from one or both queries while tabbing back & forth, would the only way to do it currently be to keep both of the queries subscribed? Or perhaps keep track of how many rows had been loaded and pass that in as the initialNumItems?
Thanks for the response @alexcole
@ian , such nuances make me wonder if having dedicated queries would be the best approach so state can be managed more cleanly.
@ian The easiest way to keep the extra pages around is to continue rendering the old
usePaginatedQuery
hook and stay subscribed.But if the same query is changing args about based on the view...
And only rendering one or the other list depending on the view, was my thought
Yeah then that won't work.
If you wanted to stay subscribed, you could have a component with two calls to
usePaginatedQuery
, one for inbox and one for archived. You could decide which results to use when rendering based on a prop
Then as the user switches back and forth the data from the other will stay loaded and up to dateah right, because you'll have two cache stores accounting for args as part of the key sig?
Yeah, the client will handle both of these totally separately
I think then you're right -- optimistically updating each cache is where the main challenge lies
I'll take a look at the docs you provided, but it seems like getting/setting cache is tricky with the paginated queries because of the cursor arg, and identifying and updating each appropriately may be difficult if they are a single query. Can I tell which is the archived query and which is the inbox query when using
getAllQueries
?
(eg: to know wish to push and which to pull from)Can I tell which is the archived query and which is the inbox query when using getAllQueries?Yeah thats the trick! It returns an array of
{ args, value }
objects. So you can inspect the args
to determine whether you want to update this page.
And obviously if you have separate query names then getAllQueries
will retrieve them separatelyGot it. You can see how this becomes quite complex if we go from a binary view, to something like gmail that could have a half dozen views plus
n
labels you'd need to account for.
I don't have a proposed solution necessarily, but definitely interested in how you all continue to improve optimistic responses and cache management.
I know similar challenges exist with Apollos strategies for local cache. The one advantage they do have around optimistic responses is that they have a doc-level cache, which queries keep references to. So if the doc changes, its reflected in respective queries.Absolutely. I definitely want to keep iterating on our optimistic updates to make them more powerful. And some day I'd love to have a normalized store like Apollo and Relay have.
But today, If your optimistic updates are becoming burdensome some other options you have today are:
- Skip some of them. The date should be synced from the server soon, so it'll just be a small performance hit.
- Set some query pages to
undefined
. That'll make them appear to be loading until the mutation completes.
- Handle the update manually in the React component. Depending on what your optimistic update does, this could be easier or harder.>update manually in the React component
I've been thinking about this approach for a few things. One way that could help both hydrate a query before initial load, and also deal with optimistic responses is if there was a way to pass in an initial state until the authoritative data from the server can replace.
eg:
const message = useQuery('message', messageId).hydrate(messageFixture)
That way when there is a local optimistic change, I could just update what I'm hydrating with.
Additionally this deals with another scenario I'm wrestling with. Lets say we have a list view and a detail view. If a user is coming from a list item to its respective detail view, I have the data (or the majority of the data) and the query can be hydrated from that. But if the user is coming from another entrypoint into the view, I will need to fetch it from the server.
Obviously I could do something like:
{message?.to || fixture?.to || 'loading...'}
for every item, but its not as clean and wouldnt handle for the optimistic case.
(well, i suppose i could inverse the priority and maybe account for the optimistic case... but code is starting to get ugly af) -- actually then i'd never get the server state and my fixture would always take priority... nmif there was a way to pass in an initial state until the authoritative data from the server can replace.FYI that you can create this today by checking if the result of the query is
undefined
.
If a user is coming from a list item to its respective detail view, I have the data (or the majority of the data)Yep, this is something that Convex definitely doesn't handle very well right now. I think doing this manually as you describe is your best option for today. But yeah I have thoughts on how we could someday build a GraphQL layer on top of Convex. If we used a structured query language like GraphQL, the Convex client could have a better sense of how different query results relate to each other.
Interesting. Joining/graphing is another area I'd like to see better support for, so keep me posted.
and yes I could do something like:
const message = useQuery('message', messageId) || messageFixture;
but this doesnt handle the optimistic case and I would have to bake in additional type checking to make sure my fixture matched the query response.