Skip to content

Conversation

@chenxin-yan
Copy link

@chenxin-yan chenxin-yan commented Oct 24, 2025

As discussed in this discord thread, I've created a minimal demo repo to showcase the DX and UX differences between with/without skip option for usePreloadedQuery

The primary motivation for this is it would be nice to have an elegant and intuitive api for waiting for auth token to be loaded on client without blocking other parts of the component. With current api, to achieve this you can create another client component and wrap it with <Authenticated/>, but it becomes tedious in a lot of cases where you do not want to decouple the rendering logic to another component.

before:

import { preloadQuery } from "convex/nextjs";
import { getAuthToken } from "@/lib/convex";
import { api } from "../../../convex/_generated/api";
import { AuthenticatedContent } from "./components/AuthenticatedContent";
import { TodoList } from "./components/TodoList";

const OldPage = async () => {
  const token = await getAuthToken();
  const preloadedTodos = await preloadQuery(api.tasks.get, {}, { token });

  return (
    <AuthenticatedContent>
      <TodoList preloadedTodos={preloadedTodos} />
    </AuthenticatedContent>
  );
};

export default OldPage;
"use client";

import { type Preloaded, usePreloadedQuery } from "convex/react";
import type { api } from "../../../../convex/_generated/api";

interface Props {
  preloadedTodos: Preloaded<typeof api.tasks.get>;
}

export function TodoList({ preloadedTodos }: Props) {
  const todos = usePreloadedQuery(preloadedTodos);

  return (
    <>
      <h1>some todos:</h1>
      <ul>
        {todos.map((todo) => (
          <li key={todo._id}>{todo.text}</li>
        ))}
      </ul>
      <p>above are my todos</p>
    </>
  );
}

2025-10-23 23 22 43

After:

import { getAuthToken } from "@/lib/convex";
import { preloadQuery } from "convex/nextjs";
import { api } from "../../../convex/_generated/api";
import { TodoList } from "./components/TodoList";

const NewPage = async () => {
  const token = await getAuthToken();
  const preloadedTodos = await preloadQuery(api.tasks.get, {}, { token });

  return <TodoList preloadedTodos={preloadedTodos} />;
};

export default NewPage;
"use client";

import { type Preloaded, useConvexAuth, usePreloadedQuery } from "convex/react";
import type { api } from "../../../../convex/_generated/api";

interface Props {
  preloadedTodos: Preloaded<typeof api.tasks.get>;
}

export function TodoList({ preloadedTodos }: Props) {
  const { isLoading } = useConvexAuth();
  const todos = usePreloadedQuery(preloadedTodos, { skip: isLoading });

  return (
    <>
      <h1>some todos:</h1>
      <ul>
        {todos ? (
          todos.map((todo) => <li key={todo._id}>{todo.text}</li>)
        ) : (
          <p>loading...</p>
        )}
      </ul>
      <p>above are my todos</p>
    </>
  );
}

2025-10-23 23 23 28

Closes #98

I am not sure if I missed anything from documentation that can handle this more elegantly, but it is weird to me that you can skip useQuery but not usePreloadedQuery.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@ianmacartney
Copy link
Contributor

Great call. I think you’re right. We need to add this or something like it.
I’m biased towards syntax like
usePreloadedQuery(isLoading? “skip”: preloadedTodos)
That way you don’t have to have preloadedTodos type check when skipping. For instance, maybe you want to skip a preloaded query based on some union of arguments where sometimes you’ll render a page with the preloadedTodos as null/undefined.
wdyt @chenxin-yan ?

@ianmacartney
Copy link
Contributor

Another thing that sticks out is now everyone will need to handle undefined even if they never use Skipp. We could solve this with an overload (not my favorite) but on the surface it’s a breaking change right now.

@chenxin-yan
Copy link
Author

Great call. I think you’re right. We need to add this or something like it.

I’m biased towards syntax like

usePreloadedQuery(isLoading? “skip”: preloadedTodos)

That way you don’t have to have preloadedTodos type check when skipping. For instance, maybe you want to skip a preloaded query based on some union of arguments where sometimes you’ll render a page with the preloadedTodos as null/undefined.

wdyt @chenxin-yan ?

I agree. It will be more consistent and predictable since useQuery uses similar syntax. I will make the change.

@chenxin-yan
Copy link
Author

chenxin-yan commented Nov 6, 2025

Another thing that sticks out is now everyone will need to handle undefined even if they never use Skipp. We could solve this with an overload (not my favorite) but on the surface it’s a breaking change right now.

I already have an idea of how to address this. I will test it out when I got back home and let you know.

@chenxin-yan
Copy link
Author

@ianmacartney I just update the changes to what we've discussed. You were right. I don't see better way to handle capability other than overriding the function (which is what I implemented). Everything is working and tested in this repo. The only thing am a little unsure about is this:

  const result = useQuery(
    skip
      ? (makeFunctionReference("_skip") as Query)
      : (makeFunctionReference(preloadedQuery._name) as Query),
    skip ? ("skip" as const) : args,
  );

where I have to create this dummy placeholder for makeFunctionReference("_skip") as useQuery does not take in undefined or null. imo it works but might not be the most elegant solution. Just to point it out here in case you have better way of implementing this. Thanks for getting back to me.

@ianmacartney
Copy link
Contributor

Here's an API I'm considering for the core useQuery which maybe can serve all purposes:

const { status, error, value } = useQuery({
  query: api.foo.bar,
  args: { ... },
  throwOnError: false,  // default
  initialValue: ...     // optional fallback
});

// with skip:
const { status, error, value } = useQuery(shouldSkip? "skip" ? { ... });

// Or with preloaded queries:
const { status, error, value } = useQuery({
  preloaded: preloadedQuery,
  throwOnError: false
});

wdyt @chenxin-yan ?

@chenxin-yan
Copy link
Author

Here's an API I'm considering for the core useQuery which maybe can serve all purposes:

const { status, error, value } = useQuery({
  query: api.foo.bar,
  args: { ... },
  throwOnError: false,  // default
  initialValue: ...     // optional fallback
});

// with skip:
const { status, error, value } = useQuery(shouldSkip? "skip" ? { ... });

// Or with preloaded queries:
const { status, error, value } = useQuery({
  preloaded: preloadedQuery,
  throwOnError: false
});

wdyt @chenxin-yan ?

Yes, I like the fact that errors are handled as values. Its getting closer to how react query implemented things. From the examples you gave, does that mean we will be unifying usePreloadedQuery and useQuery?

@ianmacartney
Copy link
Contributor

ianmacartney commented Nov 13, 2025 via email

@chenxin-yan
Copy link
Author

chenxin-yan commented Nov 13, 2025

@ianmacartney No, I think it's good to combine them. Let me know if there's anything I can help. Would love to help out.

Another thing that I was running into was that for a preloadedQuery like this where I passed in the auth token to the query:

const preloadedTasks = await preloadQuery(
    api.tasks.list,
    { list: "default" },
    { token },
  );

When preloadedTasks is passed to the client, the authenticated check is still needed. To "hydrate" the preloaded query on client, it still has to wait for the auth token to be loaded on client. It would be more intuitive and better to serialize the token in some ways and passing it to the client so we can just use that to send off queries. I haven't look into the implementation details for this yet, and am wondering if its a good idea.

@erquhart
Copy link
Contributor

erquhart commented Nov 16, 2025

The initial hydration doesn't wait for client side auth, the preloaded result just gets cleared out by useQuery. I'd like to see something like requireAuth: true on usePreloadedQuery (or Ian's useQuery concept that supports preloading).

Today you can accomplish fluid authenticated ssr in next by capturing state:

const { isLoading } = useConvexAuth();
const queryData = usePreloadedQuery(preloadedUserQuery);
const [data, setData] = useState(data);
useEffect(() => {
  if (!isLoading) {
    setData(queryData);
  }
}, [queryData, isLoading]);

Here data has the preloaded data, and it remains unchanged until auth finishes loading.

skip can be used to help this, we would just need to return the preloaded data for initial render, but that still means every preloaded authenticated query requires the user to check auth themselves to determine when to skip, and that feels superfluous. Something like this would probably get a lot of usage:

const data = usePreloadedQuery(preloadedQuery, { requireAuth: true });
// or
const data = useQuery({
  preloaded: preloadedQuery,
  requireAuth: true,
})

I'd also say either of these signatures can still support skip: true additionally, which feels better than 'skip' for the api we seem to be headed toward here anyway.

@chenxin-yan
Copy link
Author

@erquhart Thanks for providing that context. I tried capturing the state but I got the same error as before:

image

You can checkout my implementation here to see if I missed anything. Even if that works, imo it is tedious and not elegant. its better to take care of that at the package level.

Also, responding to your point on requireAuth. I think you are right and I agree. By abstracting away the logic for handling auth, it is more elegant so that user doesn't have to set up logic to decide whether the query should be skipped.

@erquhart
Copy link
Contributor

Ah, I forgot I was setting expectAuth: true in the ConvexReactClient constructor, yeah we would need package support to work without that. Agree that no matter what this needs to be solved at the package level.

@chenxin-yan
Copy link
Author

chenxin-yan commented Nov 16, 2025

Ah, I forgot I was setting expectAuth: true in the ConvexReactClient constructor, yeah we would need package support to work without that. Agree that no matter what this needs to be solved at the package level.

Yes, that indeed fixed the error. expectAuth is the option that I am looking for for the entire time. It is a little unintuitive to set it when initiating the client and I don't think its there on the doc for setting up auth. Might need to update that.

Agree on we should put the requireAuth or expectAuth at the query level and/or Ian's suggestion of unifying useQuery and usePreloadedQuery

@erquhart
Copy link
Contributor

erquhart commented Nov 17, 2025

It's not an ideal solution, but it's a useful stop gap for now. expectAuth holds all queries until the first auth attempt is completed, and then proceeds whether the user is authenticated or not. It might be better to take the requireAuth concept all the way - nothing runs if you're not authenticated, period. It could be set in the client, and that becomes the default for all useQuery/Mutation/Action usage in the app (or technically within the provider. Then individual useQuery/Mutation/Action hooks can optionally override.

This would allow everything to be released non-breaking, with requireAuth: false as the default and individual functions can set requireAuth: true. I suspect most apps would set requireAuth: true on the client, and then set requireAuth: false on any functions that they want to run unauthenticated, which I think is more of a rarity.

@chenxin-yan
Copy link
Author

It's not an ideal solution, but it's a useful stop gap for now. expectAuth holds all queries until the first auth attempt is completed, and then proceeds whether the user is authenticated or not. It might be better to take the requireAuth concept all the way - nothing runs if you're not authenticated, period. It could be set in the client, and that becomes the default for all useQuery/Mutation/Action usage in the app (or technically within the provider. Then individual useQuery/Mutation/Action hooks can optionally override.

This would allow everything to be released non-breaking, with requireAuth: false as the default and individual functions can set requireAuth: true. I suspect most apps would set requireAuth: true on the client, and then set requireAuth: false on any functions that they want to run unauthenticated, which I think is more of a rarity.

Sounds good. I'm gonna close this PR for now. We should probably proceed with Ian's idea eventually, and we can add requireAuth to useQuery to handle this. Is Ian working on this? or I can try make a PR for this.

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.

error when using preload query with authentication

3 participants