@@ -36,6 +36,10 @@ export const setupConvex = (url: string, options: ConvexClientOptions = {}) => {
3636 $effect ( ( ) => ( ) => client . close ( ) ) ;
3737} ;
3838
39+ // Internal sentinel for "skip" so we don't pass the literal string through everywhere
40+ const SKIP = Symbol ( 'convex.useQuery.skip' ) ;
41+ type Skip = typeof SKIP ;
42+
3943type UseQueryOptions < Query extends FunctionReference < 'query' > > = {
4044 // Use this data and assume it is up to date (typically for SSR and hydration)
4145 initialData ?: FunctionReturnType < Query > ;
@@ -53,153 +57,192 @@ type UseQueryReturn<Query extends FunctionReference<'query'>> =
5357 * Subscribe to a Convex query and return a reactive query result object.
5458 * Pass reactive args object or a closure returning args to update args reactively.
5559 *
56- * @param query - a FunctionRefernece like `api.dir1.dir2.filename.func`.
57- * @param args - The arguments to the query function.
60+ * Supports React-style `"skip"` to avoid subscribing:
61+ * useQuery(api.users.get, () => (isAuthed ? {} : 'skip'))
62+ *
63+ * @param query - a FunctionReference like `api.dir1.dir2.filename.func`.
64+ * @param args - Arguments object / closure, or the string `"skip"` (or a closure returning it).
5865 * @param options - UseQueryOptions like `initialData` and `keepPreviousData`.
5966 * @returns an object containing data, isLoading, error, and isStale.
6067 */
6168export function useQuery < Query extends FunctionReference < 'query' > > (
6269 query : Query ,
63- args : FunctionArgs < Query > | ( ( ) => FunctionArgs < Query > ) ,
70+ args : FunctionArgs < Query > | 'skip' | ( ( ) => FunctionArgs < Query > | 'skip' ) = { } ,
6471 options : UseQueryOptions < Query > | ( ( ) => UseQueryOptions < Query > ) = { }
6572) : UseQueryReturn < Query > {
6673 const client = useConvexClient ( ) ;
6774 if ( typeof query === 'string' ) {
6875 throw new Error ( 'Query must be a functionReference object, not a string' ) ;
6976 }
77+
7078 const state : {
7179 result : FunctionReturnType < Query > | Error | undefined ;
7280 // The last result we actually received, if this query has ever received one.
7381 lastResult : FunctionReturnType < Query > | Error | undefined ;
7482 // The args (query key) of the last result that was received.
75- argsForLastResult : FunctionArgs < Query > ;
83+ argsForLastResult : FunctionArgs < Query > | Skip | undefined ;
7684 // If the args have never changed, fine to use initialData if provided.
7785 haveArgsEverChanged : boolean ;
7886 } = $state ( {
7987 result : parseOptions ( options ) . initialData ,
80- argsForLastResult : undefined ,
8188 lastResult : undefined ,
89+ argsForLastResult : undefined ,
8290 haveArgsEverChanged : false
8391 } ) ;
8492
8593 // When args change we need to unsubscribe to the old query and subscribe
8694 // to the new one.
8795 $effect ( ( ) => {
8896 const argsObject = parseArgs ( args ) ;
97+
98+ // If skipped, don't create any subscription
99+ if ( argsObject === SKIP ) {
100+ // Clear transient result to mimic React: not loading, no data
101+ state . result = undefined ;
102+ state . argsForLastResult = SKIP ;
103+ return ;
104+ }
105+
89106 const unsubscribe = client . onUpdate (
90107 query ,
91108 argsObject ,
92109 ( dataFromServer ) => {
93110 const copy = structuredClone ( dataFromServer ) ;
94-
95111 state . result = copy ;
96112 state . argsForLastResult = argsObject ;
97113 state . lastResult = copy ;
98114 } ,
99115 ( e : Error ) => {
100116 state . result = e ;
101117 state . argsForLastResult = argsObject ;
102- // is it important to copy the error here?
103118 const copy = structuredClone ( e ) ;
104119 state . lastResult = copy ;
105120 }
106121 ) ;
122+
123+ // Cleanup on args change/unmount
107124 return unsubscribe ;
108125 } ) ;
109126
110- // Are the args (the query key) the same as the last args we received a result for?
127+ /*
128+ ** staleness & args tracking **
129+ * Are the args (the query key) the same as the last args we received a result for?
130+ */
131+ const currentArgs = $derived ( parseArgs ( args ) ) ;
132+ const initialArgs = parseArgs ( args ) ;
133+
111134 const sameArgsAsLastResult = $derived (
112- ! ! state . argsForLastResult &&
113- JSON . stringify ( convexToJson ( state . argsForLastResult ) ) ===
114- JSON . stringify ( convexToJson ( parseArgs ( args ) ) )
135+ state . argsForLastResult !== undefined &&
136+ currentArgs !== SKIP &&
137+ state . argsForLastResult !== SKIP &&
138+ jsonEqualArgs (
139+ state . argsForLastResult as Record < string , Value > ,
140+ currentArgs as Record < string , Value >
141+ )
115142 ) ;
143+
116144 const staleAllowed = $derived ( ! ! ( parseOptions ( options ) . keepPreviousData && state . lastResult ) ) ;
145+ const isSkipped = $derived ( currentArgs === SKIP ) ;
117146
118- // Not reactive
119- const initialArgs = parseArgs ( args ) ;
120147 // Once args change, move off of initialData.
121148 $effect ( ( ) => {
122149 if ( ! untrack ( ( ) => state . haveArgsEverChanged ) ) {
123- if (
124- JSON . stringify ( convexToJson ( parseArgs ( args ) ) ) !== JSON . stringify ( convexToJson ( initialArgs ) )
125- ) {
150+ const curr = parseArgs ( args ) ;
151+ if ( ! argsKeyEqual ( initialArgs , curr ) ) {
126152 state . haveArgsEverChanged = true ;
127153 const opts = parseOptions ( options ) ;
128154 if ( opts . initialData !== undefined ) {
129- state . argsForLastResult = $state . snapshot ( initialArgs ) ;
130- state . lastResult = parseOptions ( options ) . initialData ;
155+ state . argsForLastResult = initialArgs === SKIP ? SKIP : $state . snapshot ( initialArgs ) ;
156+ state . lastResult = opts . initialData ;
131157 }
132158 }
133159 }
134160 } ) ;
135161
136- // Return value or undefined; never an error object.
162+ /*
163+ ** compute sync result **
164+ * Return value or undefined; never an error object.
165+ */
137166 const syncResult : FunctionReturnType < Query > | undefined = $derived . by ( ( ) => {
167+ if ( isSkipped ) return undefined ;
168+
138169 const opts = parseOptions ( options ) ;
139170 if ( opts . initialData && ! state . haveArgsEverChanged ) {
140171 return state . result ;
141172 }
173+
142174 let value ;
143175 try {
144176 value = client . disabled
145177 ? undefined
146- : client . client . localQueryResult ( getFunctionName ( query ) , parseArgs ( args ) ) ;
178+ : client . client . localQueryResult (
179+ getFunctionName ( query ) ,
180+ currentArgs as Record < string , Value >
181+ ) ;
147182 } catch ( e ) {
148183 if ( ! ( e instanceof Error ) ) {
149- // This should not happen by the API of localQueryResult().
150184 console . error ( 'threw non-Error instance' , e ) ;
151185 throw e ;
152186 }
153187 value = e ;
154188 }
155- // If state result has updated then it's time to check the for a new local value
189+ // Touch reactive state. result so updates retrigger computations
156190 state . result ;
157191 return value ;
158192 } ) ;
159193
160194 const result = $derived . by ( ( ) => {
161195 return syncResult !== undefined ? syncResult : staleAllowed ? state . lastResult : undefined ;
162196 } ) ;
197+
163198 const isStale = $derived (
164- syncResult === undefined && staleAllowed && ! sameArgsAsLastResult && result !== undefined
199+ ! isSkipped &&
200+ syncResult === undefined &&
201+ staleAllowed &&
202+ ! sameArgsAsLastResult &&
203+ result !== undefined
165204 ) ;
205+
166206 const data = $derived . by ( ( ) => {
167- if ( result instanceof Error ) {
168- return undefined ;
169- }
207+ if ( result instanceof Error ) return undefined ;
170208 return result ;
171209 } ) ;
210+
172211 const error = $derived . by ( ( ) => {
173- if ( result instanceof Error ) {
174- return result ;
175- }
212+ if ( result instanceof Error ) return result ;
176213 return undefined ;
177214 } ) ;
178215
179- // This TypeScript cast promises data is not undefined if error and isLoading are checked first.
216+ /*
217+ ** public shape **
218+ * This TypeScript cast promises data is not undefined if error and isLoading are checked first.
219+ */
180220 return {
181221 get data ( ) {
182222 return data ;
183223 } ,
184224 get isLoading ( ) {
185- return error === undefined && data === undefined ;
225+ return isSkipped ? false : error === undefined && data === undefined ;
186226 } ,
187227 get error ( ) {
188228 return error ;
189229 } ,
190230 get isStale ( ) {
191- return isStale ;
231+ return isSkipped ? false : isStale ;
192232 }
193233 } as UseQueryReturn < Query > ;
194234}
195235
196- // args can be an object or a closure returning one
236+ /**
237+ * args can be an object, "skip", or a closure returning either
238+ **/
197239function parseArgs (
198- args : Record < string , Value > | ( ( ) => Record < string , Value > )
199- ) : Record < string , Value > {
240+ args : Record < string , Value > | 'skip' | ( ( ) => Record < string , Value > | 'skip' )
241+ ) : Record < string , Value > | Skip {
200242 if ( typeof args === 'function' ) {
201243 args = args ( ) ;
202244 }
245+ if ( args === 'skip' ) return SKIP ;
203246 return $state . snapshot ( args ) ;
204247}
205248
@@ -212,3 +255,13 @@ function parseOptions<Query extends FunctionReference<'query'>>(
212255 }
213256 return $state . snapshot ( options ) ;
214257}
258+
259+ function jsonEqualArgs ( a : Record < string , Value > , b : Record < string , Value > ) : boolean {
260+ return JSON . stringify ( convexToJson ( a ) ) === JSON . stringify ( convexToJson ( b ) ) ;
261+ }
262+
263+ function argsKeyEqual ( a : Record < string , Value > | Skip , b : Record < string , Value > | Skip ) : boolean {
264+ if ( a === SKIP && b === SKIP ) return true ;
265+ if ( a === SKIP || b === SKIP ) return false ;
266+ return jsonEqualArgs ( a , b ) ;
267+ }
0 commit comments