Skip to content

Commit 790f352

Browse files
robelestConvex, Inc.
authored andcommitted
Add object-form useQuery overload with result state and throwOnError (#47801)
Adds an object-form overload to `useQuery` that accepts `{ query, args, throwOnError? }` and returns a `UseQueryResult<T>` discriminated union (`success | error | pending`). - Renames `ConvexQueryOptions` → `QueryOptions`, moves `extendSubscriptionFor` off the shared type so the base is just `{ query, args }`. - Adds `parseArgs` validation for object-form args. - Includes type tests and runtime tests. Stack: 1/6 GitOrigin-RevId: d7713b83577189447ae16e35add2dd1af0fe513c
1 parent 8250ca8 commit 790f352

File tree

10 files changed

+352
-40
lines changed

10 files changed

+352
-40
lines changed

src/browser/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ export type { QueryJournal } from "./sync/protocol.js";
4040
/** @internal */
4141
export type { UserIdentityAttributes } from "./sync/protocol.js";
4242
export type { FunctionResult } from "./sync/function_result.js";
43+
/** @internal */
44+
export { convexQueryOptions } from "./query_options.js";
45+
export type { QueryOptions } from "./query_options.js";

src/browser/query_options.test.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { test } from "vitest";
22
import { makeFunctionReference } from "../server/index.js";
33
import { EmptyObject } from "../server/registration.js";
4-
import { ConvexReactClient } from "../react/client.js";
54
import { convexQueryOptions } from "./query_options.js";
65

76
const apiQueryFuncWithArgs = makeFunctionReference<
@@ -42,11 +41,3 @@ test("convexQueryOptions", async () => {
4241
args: { name: "hey" },
4342
});
4443
});
45-
46-
test("prewarmQuery types", async () => {
47-
const client = {
48-
prewarmQuery: () => {},
49-
} as unknown as ConvexReactClient;
50-
51-
client.prewarmQuery({ query: apiQueryFuncWithArgs, args: { name: "hi" } });
52-
});

src/browser/query_options.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,47 @@
1-
/**
2-
* Query options are a potential new API for a variety of functions, but in particular a new overload of the React hook for queries.
3-
*
4-
* Inspired by https://tanstack.com/query/v5/docs/framework/react/guides/query-options
5-
*/
1+
// Inspired by https://tanstack.com/query/v5/docs/framework/react/guides/query-options
62
import type { FunctionArgs, FunctionReference } from "../server/api.js";
73

8-
// TODO if this type can encompass all use cases we can add not requiring args for queries
9-
// that don't take arguments. Goal would be that queryOptions allows leaving out args,
10-
// but queryOptions returns an object that always contains args. Helpers, "middleware,"
11-
// anything that intercepts these arguments
124
/**
13-
* Query options.
5+
* Options for a Convex query: the query function reference and its arguments.
6+
*
7+
* Used with the object-form overload of {@link useQuery}.
8+
*
9+
* @public
1410
*/
15-
export type ConvexQueryOptions<Query extends FunctionReference<"query">> = {
11+
export type QueryOptions<Query extends FunctionReference<"query">> = {
12+
/**
13+
* The query function to run.
14+
*/
1615
query: Query;
16+
/**
17+
* The arguments to the query function.
18+
*/
1719
args: FunctionArgs<Query>;
18-
extendSubscriptionFor?: number;
1920
};
2021

21-
// This helper helps more once we have more inference happening.
22+
/**
23+
* Creates a type-safe {@link QueryOptions} object for a Convex query.
24+
*
25+
* This is an identity function that exists to provide type inference — passing
26+
* your query and args through this helper ensures TypeScript infers the correct
27+
* `Query` type parameter, which enables precise return types on hooks like
28+
* {@link useQuery}.
29+
*
30+
* ```typescript
31+
* const opts = convexQueryOptions({
32+
* query: api.users.getById,
33+
* args: { id: userId },
34+
* });
35+
* // opts is typed as QueryOptions<typeof api.users.getById>
36+
* client.prewarmQuery(opts);
37+
* ```
38+
*
39+
* @param options - The query and its arguments.
40+
* @returns The same object, typed as `QueryOptions<Query>`.
41+
* @internal
42+
*/
2243
export function convexQueryOptions<Query extends FunctionReference<"query">>(
23-
options: ConvexQueryOptions<Query>,
24-
): ConvexQueryOptions<Query> {
44+
options: QueryOptions<Query>,
45+
): QueryOptions<Query> {
2546
return options;
2647
}

src/cli/codegen_templates/readme.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,20 @@ export const myQueryFunction = query({
3737
Using this query function in a React component looks like:
3838
3939
\`\`\`ts
40-
const data = useQuery(api.myFunctions.myQueryFunction, { first: 10, second: "hello" });
40+
const state = useQuery({
41+
query: api.myFunctions.myQueryFunction,
42+
args: { first: 10, second: "hello" },
43+
});
44+
45+
if (state.status === "pending") {
46+
return <div>Loading...</div>;
47+
}
48+
49+
if (state.status === "error") {
50+
return <div>Error: {state.error.message}</div>;
51+
}
52+
53+
const data = state.data;
4154
\`\`\`
4255
4356

src/react/client.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { test, expect, describe, vi } from "vitest";
55
import ws from "ws";
66

77
import { ConvexReactClient, createMutation, useQuery } from "./client.js";
8+
import { convexQueryOptions } from "../browser/query_options.js";
89
import { ConvexProvider } from "./index.js";
910
import React from "react";
1011
import { renderHook } from "@testing-library/react";
@@ -113,6 +114,46 @@ describe("useQuery", () => {
113114
expect(result.current).toStrictEqual(undefined);
114115
});
115116

117+
test("object form returns success result", () => {
118+
const client = createClientWithQuery();
119+
const wrapper = ({ children }: any) => (
120+
<ConvexProvider client={client}>{children}</ConvexProvider>
121+
);
122+
const { result } = renderHook(
123+
() =>
124+
useQuery({
125+
query: anyApi.myQuery.default,
126+
args: {},
127+
}),
128+
{ wrapper },
129+
);
130+
expect(result.current).toStrictEqual({
131+
data: "queryResult",
132+
error: undefined,
133+
status: "success",
134+
});
135+
});
136+
137+
test("object form returns pending when skipped", () => {
138+
const client = createClientWithQuery();
139+
const wrapper = ({ children }: any) => (
140+
<ConvexProvider client={client}>{children}</ConvexProvider>
141+
);
142+
const { result } = renderHook(
143+
() =>
144+
useQuery({
145+
query: anyApi.myQuery.default,
146+
args: "skip",
147+
}),
148+
{ wrapper },
149+
);
150+
expect(result.current).toStrictEqual({
151+
data: undefined,
152+
error: undefined,
153+
status: "pending",
154+
});
155+
});
156+
116157
test("Optimistic update handlers can’t be async", () => {
117158
const client = testConvexReactClient();
118159
const mutation = createMutation(
@@ -192,3 +233,27 @@ describe("async query fetch", () => {
192233
expect(await queryResult).toStrictEqual("queryResult");
193234
});
194235
});
236+
237+
describe("prewarmQuery types", () => {
238+
test("accepts QueryOptions shape", () => {
239+
const client = testConvexReactClient();
240+
const opts = convexQueryOptions({
241+
query: makeFunctionReference<"query", { name: string }, string>(
242+
"myQuery",
243+
),
244+
args: { name: "hi" },
245+
});
246+
client.prewarmQuery(opts);
247+
});
248+
249+
test("accepts extendSubscriptionFor on prewarmQuery", () => {
250+
const client = testConvexReactClient();
251+
client.prewarmQuery({
252+
query: makeFunctionReference<"query", { name: string }, string>(
253+
"myQuery",
254+
),
255+
args: { name: "hi" },
256+
extendSubscriptionFor: 10_000,
257+
});
258+
});
259+
});

src/react/client.ts

Lines changed: 129 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
instantiateNoopLogger,
3333
Logger,
3434
} from "../browser/logging.js";
35-
import { ConvexQueryOptions } from "../browser/query_options.js";
35+
import type { QueryOptions } from "../browser/query_options.js";
3636
import { LoadMoreOfPaginatedQuery } from "../browser/sync/pagination.js";
3737
import {
3838
PaginatedQueryClient,
@@ -537,9 +537,7 @@ export class ConvexReactClient {
537537
* an optional extendSubscriptionFor for how long to subscribe to the query.
538538
*/
539539
prewarmQuery<Query extends FunctionReference<"query">>(
540-
queryOptions: ConvexQueryOptions<Query> & {
541-
extendSubscriptionFor?: number;
542-
},
540+
queryOptions: QueryOptions<Query> & { extendSubscriptionFor?: number },
543541
) {
544542
const extendSubscriptionFor =
545543
queryOptions.extendSubscriptionFor ?? DEFAULT_EXTEND_SUBSCRIPTION_FOR;
@@ -801,6 +799,34 @@ export type OptionalRestArgsOrSkip<FuncRef extends FunctionReference<any>> =
801799
? [args?: EmptyObject | "skip"]
802800
: [args: FuncRef["_args"] | "skip"];
803801

802+
/**
803+
* Result returned by object-form {@link useQuery}.
804+
*
805+
* @public
806+
*/
807+
export type UseQueryResult<QueryResult> =
808+
| {
809+
data: QueryResult;
810+
error: undefined;
811+
status: "success";
812+
}
813+
| {
814+
data: undefined;
815+
error: Error;
816+
status: "error";
817+
}
818+
| {
819+
data: undefined;
820+
error: undefined;
821+
status: "pending";
822+
};
823+
824+
type UseQueryOptions<Query extends FunctionReference<"query">> = {
825+
query: Query;
826+
args: FunctionArgs<Query> | "skip";
827+
throwOnError?: boolean;
828+
};
829+
804830
/**
805831
* Load a reactive query within a React component.
806832
*
@@ -847,20 +873,82 @@ export type OptionalRestArgsOrSkip<FuncRef extends FunctionReference<any>> =
847873
export function useQuery<Query extends FunctionReference<"query">>(
848874
query: Query,
849875
...args: OptionalRestArgsOrSkip<Query>
850-
): Query["_returnType"] | undefined {
851-
const skip = args[0] === "skip";
852-
const argsObject = args[0] === "skip" ? {} : parseArgs(args[0]);
876+
): Query["_returnType"] | undefined;
877+
878+
/**
879+
* Load a reactive query within a React component using an options object.
880+
*
881+
* This is an alternative form of {@link useQuery} that accepts a single
882+
* {@link UseQueryOptions} object instead of positional arguments.
883+
* Errors are returned in the result object unless `throwOnError` is set.
884+
*
885+
* @example
886+
* ```tsx
887+
* import { useQuery } from "convex/react";
888+
* import { api } from "../convex/_generated/api";
889+
*
890+
* function TaskList() {
891+
* const state = useQuery({ query: api.tasks.list, args: { completed: false } });
892+
*
893+
* if (state.status === "pending") return <div>Loading...</div>;
894+
* if (state.status === "error") return <div>Error: {state.error.message}</div>;
895+
* return state.data.map((task) => <div key={task._id}>{task.text}</div>);
896+
* }
897+
* ```
898+
*
899+
* @param options - Query options. Pass `args: "skip"` to disable the query.
900+
* @returns the current query state as a {@link UseQueryResult} object.
901+
*
902+
* @see https://docs.convex.dev/client/react#fetching-data
903+
* @public
904+
*/
905+
export function useQuery<Query extends FunctionReference<"query">>(
906+
options: UseQueryOptions<Query>,
907+
): UseQueryResult<Query["_returnType"]>;
853908

854-
const queryReference =
855-
typeof query === "string"
856-
? makeFunctionReference<"query", any, any>(query)
857-
: query;
909+
export function useQuery<Query extends FunctionReference<"query">>(
910+
queryOrOptions: Query | UseQueryOptions<Query>,
911+
...args: OptionalRestArgsOrSkip<Query>
912+
): Query["_returnType"] | undefined | UseQueryResult<Query["_returnType"]> {
913+
const isObjectOptions =
914+
typeof queryOrOptions === "object" &&
915+
queryOrOptions !== null &&
916+
"query" in queryOrOptions;
917+
const throwOnError = isObjectOptions
918+
? (queryOrOptions.throwOnError ?? false)
919+
: true;
920+
921+
let queryReference: Query | undefined;
922+
let argsObject: Record<string, Value> = {};
923+
924+
if (isObjectOptions) {
925+
const query = queryOrOptions.query;
926+
queryReference =
927+
typeof query === "string"
928+
? (makeFunctionReference<"query", any, any>(query) as Query)
929+
: query;
930+
if (queryOrOptions.args !== "skip") {
931+
argsObject = parseArgs(queryOrOptions.args as Record<string, Value>);
932+
}
933+
} else {
934+
const query = queryOrOptions;
935+
queryReference =
936+
typeof query === "string"
937+
? (makeFunctionReference<"query", any, any>(query) as Query)
938+
: query;
939+
argsObject = args[0] === "skip" ? {} : parseArgs(args[0] as Query["_args"]);
940+
}
858941

859-
const queryName = getFunctionName(queryReference);
942+
const queryName = queryReference
943+
? getFunctionName(queryReference)
944+
: undefined;
945+
const skip =
946+
(isObjectOptions && queryOrOptions.args === "skip") ||
947+
(!isObjectOptions && args[0] === "skip");
860948

861949
const queries = useMemo(
862950
() =>
863-
skip
951+
skip || !queryReference
864952
? ({} as RequestForQueries)
865953
: { query: { query: queryReference, args: argsObject } },
866954
// Stringify args so args that are semantically the same don't trigger a
@@ -871,6 +959,34 @@ export function useQuery<Query extends FunctionReference<"query">>(
871959

872960
const results = useQueries(queries);
873961
const result = results["query"];
962+
963+
if (isObjectOptions) {
964+
if (result instanceof Error) {
965+
if (throwOnError) {
966+
throw result;
967+
}
968+
return {
969+
data: undefined,
970+
error: result,
971+
status: "error",
972+
};
973+
}
974+
975+
if (result === undefined) {
976+
return {
977+
data: undefined,
978+
error: undefined,
979+
status: "pending",
980+
};
981+
}
982+
983+
return {
984+
data: result,
985+
error: undefined,
986+
status: "success",
987+
};
988+
}
989+
874990
if (result instanceof Error) {
875991
throw result;
876992
}

0 commit comments

Comments
 (0)