@sylphx/lens-react 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -45,8 +45,7 @@ declare function LensProvider({ client, children }: LensProviderProps): ReactEle
45
45
  */
46
46
  declare function useLensClient<TRouter = any>(): LensClient<any, any> & TRouter;
47
47
  import { MutationResult, QueryResult } from "@sylphx/lens-client";
48
- /** Query input - can be a query, null/undefined, or an accessor function */
49
- type QueryInput<T> = QueryResult<T> | null | undefined | (() => QueryResult<T> | null | undefined);
48
+ import { DependencyList } from "react";
50
49
  /** Result of useQuery hook */
51
50
  interface UseQueryResult<T> {
52
51
  /** Query data (null if loading or error) */
@@ -75,62 +74,98 @@ interface UseMutationResult<
75
74
  reset: () => void;
76
75
  }
77
76
  /** Options for useQuery */
78
- interface UseQueryOptions {
77
+ interface UseQueryOptions<
78
+ TData = unknown,
79
+ TSelected = TData
80
+ > {
79
81
  /** Skip the query (don't execute) */
80
82
  skip?: boolean;
83
+ /** Transform the query result */
84
+ select?: (data: TData) => TSelected;
81
85
  }
86
+ /** Route function type - takes params and returns QueryResult */
87
+ type RouteFunction<
88
+ TParams,
89
+ TResult
90
+ > = (params: TParams) => QueryResult<TResult>;
91
+ /** Accessor function type - returns QueryResult or null */
92
+ type QueryAccessor<T> = () => QueryResult<T> | null | undefined;
93
+ /** Mutation function type */
94
+ type MutationFn<
95
+ TInput,
96
+ TOutput
97
+ > = (input: TInput) => Promise<MutationResult<TOutput>>;
82
98
  /**
83
- * Subscribe to a query with reactive updates
99
+ * Subscribe to a query with reactive updates.
100
+ *
101
+ * Two usage patterns:
84
102
  *
85
- * @param queryInput - QueryResult, null/undefined, or accessor function returning QueryResult
86
- * @param options - Query options
103
+ * **1. Route + Params (recommended)** - Stable references, no infinite loops
104
+ * ```tsx
105
+ * const { data } = useQuery(client.user.get, { id: userId });
106
+ * ```
107
+ *
108
+ * **2. Accessor + Deps (escape hatch)** - For complex/composed queries
109
+ * ```tsx
110
+ * const { data } = useQuery(() => client.user.get({ id }).pipe(transform), [id]);
111
+ * ```
87
112
  *
88
113
  * @example
89
114
  * ```tsx
90
- * // Basic usage
115
+ * // Basic usage - Route + Params
91
116
  * function UserProfile({ userId }: { userId: string }) {
92
117
  * const client = useLensClient();
93
- * const { data: user, loading, error } = useQuery(client.user.get({ id: userId }));
118
+ * const { data: user, loading, error } = useQuery(client.user.get, { id: userId });
94
119
  *
95
120
  * if (loading) return <Spinner />;
96
121
  * if (error) return <Error message={error.message} />;
97
- * if (!user) return <NotFound />;
122
+ * return <h1>{user?.name}</h1>;
123
+ * }
98
124
  *
99
- * return <h1>{user.name}</h1>;
125
+ * // With select transform
126
+ * function UserName({ userId }: { userId: string }) {
127
+ * const client = useLensClient();
128
+ * const { data: name } = useQuery(client.user.get, { id: userId }, {
129
+ * select: (user) => user.name
130
+ * });
131
+ * return <span>{name}</span>;
100
132
  * }
101
133
  *
102
- * // Conditional query (null when condition not met)
134
+ * // Conditional query
103
135
  * function SessionInfo({ sessionId }: { sessionId: string | null }) {
104
136
  * const client = useLensClient();
105
137
  * const { data } = useQuery(
106
- * sessionId ? client.session.get({ id: sessionId }) : null
138
+ * sessionId ? client.session.get : null,
139
+ * { id: sessionId ?? '' }
107
140
  * );
108
- * // data is null when sessionId is null
109
141
  * return <span>{data?.totalTokens}</span>;
110
142
  * }
111
143
  *
112
- * // Accessor function (reactive inputs)
113
- * function ReactiveQuery({ sessionId }: { sessionId: Signal<string | null> }) {
144
+ * // Skip query
145
+ * function ConditionalQuery({ userId, shouldFetch }: { userId: string; shouldFetch: boolean }) {
114
146
  * const client = useLensClient();
115
- * const { data } = useQuery(() =>
116
- * sessionId.value ? client.session.get({ id: sessionId.value }) : null
117
- * );
118
- * return <span>{data?.totalTokens}</span>;
147
+ * const { data } = useQuery(client.user.get, { id: userId }, { skip: !shouldFetch });
119
148
  * }
120
149
  *
121
- * // Skip query conditionally
122
- * function ConditionalQuery({ shouldFetch }: { shouldFetch: boolean }) {
150
+ * // Complex queries with accessor (escape hatch)
151
+ * function ComplexQuery({ userId }: { userId: string }) {
123
152
  * const client = useLensClient();
124
- * const { data } = useQuery(client.user.list(), { skip: !shouldFetch });
153
+ * const { data } = useQuery(
154
+ * () => client.user.get({ id: userId }),
155
+ * [userId]
156
+ * );
125
157
  * }
126
158
  * ```
127
159
  */
128
- declare function useQuery<T>(queryInput: QueryInput<T>, options?: UseQueryOptions): UseQueryResult<T>;
129
- /** Mutation function type */
130
- type MutationFn<
131
- TInput,
132
- TOutput
133
- > = (input: TInput) => Promise<MutationResult<TOutput>>;
160
+ declare function useQuery<
161
+ TParams,
162
+ TResult,
163
+ TSelected = TResult
164
+ >(route: RouteFunction<TParams, TResult> | null, params: TParams, options?: UseQueryOptions<TResult, TSelected>): UseQueryResult<TSelected>;
165
+ declare function useQuery<
166
+ TResult,
167
+ TSelected = TResult
168
+ >(accessor: QueryAccessor<TResult>, deps: DependencyList, options?: UseQueryOptions<TResult, TSelected>): UseQueryResult<TSelected>;
134
169
  /**
135
170
  * Execute mutations with loading/error state
136
171
  *
@@ -196,45 +231,44 @@ interface UseLazyQueryResult<T> {
196
231
  /**
197
232
  * Execute a query on demand (not on mount)
198
233
  *
199
- * @param queryInput - QueryResult, null/undefined, or accessor function returning QueryResult
200
- *
201
234
  * @example
202
235
  * ```tsx
236
+ * // Route + Params pattern
203
237
  * function SearchUsers() {
204
238
  * const client = useLensClient();
205
239
  * const [searchTerm, setSearchTerm] = useState('');
206
240
  * const { execute, data, loading } = useLazyQuery(
207
- * client.user.search({ query: searchTerm })
241
+ * client.user.search,
242
+ * { query: searchTerm }
208
243
  * );
209
244
  *
210
- * const handleSearch = async () => {
211
- * const users = await execute();
212
- * console.log('Found:', users);
213
- * };
214
- *
215
245
  * return (
216
246
  * <div>
217
- * <input
218
- * value={searchTerm}
219
- * onChange={e => setSearchTerm(e.target.value)}
220
- * />
221
- * <button onClick={handleSearch} disabled={loading}>
222
- * Search
223
- * </button>
247
+ * <input value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
248
+ * <button onClick={execute} disabled={loading}>Search</button>
224
249
  * {data?.map(user => <UserCard key={user.id} user={user} />)}
225
250
  * </div>
226
251
  * );
227
252
  * }
228
253
  *
229
- * // With accessor function
230
- * function LazyReactiveQuery({ sessionId }: { sessionId: Signal<string | null> }) {
254
+ * // Accessor pattern
255
+ * function LazyComplexQuery({ userId }: { userId: string }) {
231
256
  * const client = useLensClient();
232
- * const { execute, data } = useLazyQuery(() =>
233
- * sessionId.value ? client.session.get({ id: sessionId.value }) : null
257
+ * const { execute, data } = useLazyQuery(
258
+ * () => client.user.get({ id: userId }),
259
+ * [userId]
234
260
  * );
235
261
  * return <button onClick={execute}>Load</button>;
236
262
  * }
237
263
  * ```
238
264
  */
239
- declare function useLazyQuery<T>(queryInput: QueryInput<T>): UseLazyQueryResult<T>;
240
- export { useQuery, useMutation, useLensClient, useLazyQuery, UseQueryResult, UseQueryOptions, UseMutationResult, UseLazyQueryResult, QueryInput, MutationFn, LensProviderProps, LensProvider };
265
+ declare function useLazyQuery<
266
+ TParams,
267
+ TResult,
268
+ TSelected = TResult
269
+ >(route: RouteFunction<TParams, TResult> | null, params: TParams, options?: UseQueryOptions<TResult, TSelected>): UseLazyQueryResult<TSelected>;
270
+ declare function useLazyQuery<
271
+ TResult,
272
+ TSelected = TResult
273
+ >(accessor: QueryAccessor<TResult>, deps: DependencyList, options?: UseQueryOptions<TResult, TSelected>): UseLazyQueryResult<TSelected>;
274
+ export { useQuery, useMutation, useLensClient, useLazyQuery, UseQueryResult, UseQueryOptions, UseMutationResult, UseLazyQueryResult, RouteFunction, QueryAccessor, MutationFn, LensProviderProps, LensProvider };
package/dist/index.js CHANGED
@@ -17,20 +17,35 @@ function useLensClient() {
17
17
  }
18
18
  // src/hooks.ts
19
19
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20
- function resolveQuery(input) {
21
- return typeof input === "function" ? input() : input;
22
- }
23
- function useQuery(queryInput, options) {
24
- const query = useMemo(() => resolveQuery(queryInput), [queryInput]);
20
+ function useQuery(routeOrAccessor, paramsOrDeps, options) {
21
+ const isAccessorMode = Array.isArray(paramsOrDeps);
22
+ const paramsKey = !isAccessorMode ? JSON.stringify(paramsOrDeps) : null;
23
+ const query = useMemo(() => {
24
+ if (options?.skip)
25
+ return null;
26
+ if (isAccessorMode) {
27
+ const accessor = routeOrAccessor;
28
+ return accessor();
29
+ }
30
+ if (!routeOrAccessor)
31
+ return null;
32
+ const route = routeOrAccessor;
33
+ return route(paramsOrDeps);
34
+ }, isAccessorMode ? [options?.skip, ...paramsOrDeps] : [routeOrAccessor, paramsKey, options?.skip]);
35
+ const selectRef = useRef(options?.select);
36
+ selectRef.current = options?.select;
25
37
  const [data, setData] = useState(null);
26
- const [loading, setLoading] = useState(!options?.skip && query != null);
38
+ const [loading, setLoading] = useState(query != null && !options?.skip);
27
39
  const [error, setError] = useState(null);
28
40
  const mountedRef = useRef(true);
29
41
  const queryRef = useRef(query);
30
42
  queryRef.current = query;
43
+ const transform = useCallback((value) => {
44
+ return selectRef.current ? selectRef.current(value) : value;
45
+ }, []);
31
46
  useEffect(() => {
32
47
  mountedRef.current = true;
33
- if (query == null || options?.skip) {
48
+ if (query == null) {
34
49
  setData(null);
35
50
  setLoading(false);
36
51
  setError(null);
@@ -40,13 +55,13 @@ function useQuery(queryInput, options) {
40
55
  setError(null);
41
56
  const unsubscribe = query.subscribe((value) => {
42
57
  if (mountedRef.current) {
43
- setData(value);
58
+ setData(transform(value));
44
59
  setLoading(false);
45
60
  }
46
61
  });
47
62
  query.then((value) => {
48
63
  if (mountedRef.current) {
49
- setData(value);
64
+ setData(transform(value));
50
65
  setLoading(false);
51
66
  }
52
67
  }, (err) => {
@@ -59,16 +74,16 @@ function useQuery(queryInput, options) {
59
74
  mountedRef.current = false;
60
75
  unsubscribe();
61
76
  };
62
- }, [query, options?.skip]);
77
+ }, [query, transform]);
63
78
  const refetch = useCallback(() => {
64
79
  const currentQuery = queryRef.current;
65
- if (currentQuery == null || options?.skip)
80
+ if (currentQuery == null)
66
81
  return;
67
82
  setLoading(true);
68
83
  setError(null);
69
84
  currentQuery.then((value) => {
70
85
  if (mountedRef.current) {
71
- setData(value);
86
+ setData(transform(value));
72
87
  setLoading(false);
73
88
  }
74
89
  }, (err) => {
@@ -77,7 +92,7 @@ function useQuery(queryInput, options) {
77
92
  setLoading(false);
78
93
  }
79
94
  });
80
- }, [options?.skip]);
95
+ }, [transform]);
81
96
  return { data, loading, error, refetch };
82
97
  }
83
98
  function useMutation(mutationFn) {
@@ -119,13 +134,18 @@ function useMutation(mutationFn) {
119
134
  }, []);
120
135
  return { mutate, loading, error, data, reset };
121
136
  }
122
- function useLazyQuery(queryInput) {
137
+ function useLazyQuery(routeOrAccessor, paramsOrDeps, options) {
123
138
  const [data, setData] = useState(null);
124
139
  const [loading, setLoading] = useState(false);
125
140
  const [error, setError] = useState(null);
126
141
  const mountedRef = useRef(true);
127
- const queryInputRef = useRef(queryInput);
128
- queryInputRef.current = queryInput;
142
+ const isAccessorMode = Array.isArray(paramsOrDeps);
143
+ const routeOrAccessorRef = useRef(routeOrAccessor);
144
+ routeOrAccessorRef.current = routeOrAccessor;
145
+ const paramsOrDepsRef = useRef(paramsOrDeps);
146
+ paramsOrDepsRef.current = paramsOrDeps;
147
+ const selectRef = useRef(options?.select);
148
+ selectRef.current = options?.select;
129
149
  useEffect(() => {
130
150
  mountedRef.current = true;
131
151
  return () => {
@@ -133,7 +153,16 @@ function useLazyQuery(queryInput) {
133
153
  };
134
154
  }, []);
135
155
  const execute = useCallback(async () => {
136
- const query = resolveQuery(queryInputRef.current);
156
+ let query;
157
+ if (isAccessorMode) {
158
+ const accessor = routeOrAccessorRef.current;
159
+ query = accessor();
160
+ } else {
161
+ const route = routeOrAccessorRef.current;
162
+ if (route) {
163
+ query = route(paramsOrDepsRef.current);
164
+ }
165
+ }
137
166
  if (query == null) {
138
167
  setData(null);
139
168
  setLoading(false);
@@ -143,10 +172,11 @@ function useLazyQuery(queryInput) {
143
172
  setError(null);
144
173
  try {
145
174
  const result = await query;
175
+ const selected = selectRef.current ? selectRef.current(result) : result;
146
176
  if (mountedRef.current) {
147
- setData(result);
177
+ setData(selected);
148
178
  }
149
- return result;
179
+ return selected;
150
180
  } catch (err) {
151
181
  const queryError = err instanceof Error ? err : new Error(String(err));
152
182
  if (mountedRef.current) {
@@ -158,7 +188,7 @@ function useLazyQuery(queryInput) {
158
188
  setLoading(false);
159
189
  }
160
190
  }
161
- }, []);
191
+ }, [isAccessorMode]);
162
192
  const reset = useCallback(() => {
163
193
  setLoading(false);
164
194
  setError(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-react",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "React bindings for Lens API framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,14 +88,14 @@ function createMockQueryResult<T>(initialValue: T | null = null): QueryResult<T>
88
88
  }
89
89
 
90
90
  // =============================================================================
91
- // Tests: useQuery
91
+ // Tests: useQuery (Accessor + Deps pattern)
92
92
  // =============================================================================
93
93
 
94
94
  describe("useQuery", () => {
95
95
  test("returns loading state initially", () => {
96
96
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
97
97
 
98
- const { result } = renderHook(() => useQuery(mockQuery));
98
+ const { result } = renderHook(() => useQuery(() => mockQuery, []));
99
99
 
100
100
  expect(result.current.loading).toBe(true);
101
101
  expect(result.current.data).toBe(null);
@@ -105,7 +105,7 @@ describe("useQuery", () => {
105
105
  test("returns data when query resolves", async () => {
106
106
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
107
107
 
108
- const { result } = renderHook(() => useQuery(mockQuery));
108
+ const { result } = renderHook(() => useQuery(() => mockQuery, []));
109
109
 
110
110
  // Simulate data loading
111
111
  act(() => {
@@ -123,7 +123,7 @@ describe("useQuery", () => {
123
123
  test("returns error when query fails", async () => {
124
124
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
125
125
 
126
- const { result } = renderHook(() => useQuery(mockQuery));
126
+ const { result } = renderHook(() => useQuery(() => mockQuery, []));
127
127
 
128
128
  // Simulate error
129
129
  act(() => {
@@ -147,7 +147,7 @@ describe("useQuery", () => {
147
147
  },
148
148
  } as unknown as QueryResult<{ id: string }>;
149
149
 
150
- const { result } = renderHook(() => useQuery(mockQuery));
150
+ const { result } = renderHook(() => useQuery(() => mockQuery, []));
151
151
 
152
152
  await waitFor(() => {
153
153
  expect(result.current.error?.message).toBe("String error");
@@ -157,65 +157,32 @@ describe("useQuery", () => {
157
157
  test("skips query when skip option is true", () => {
158
158
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
159
159
 
160
- const { result } = renderHook(() => useQuery(mockQuery, { skip: true }));
160
+ const { result } = renderHook(() => useQuery(() => mockQuery, [], { skip: true }));
161
161
 
162
162
  expect(result.current.loading).toBe(false);
163
163
  expect(result.current.data).toBe(null);
164
164
  });
165
165
 
166
- test("handles null query", () => {
167
- const { result } = renderHook(() => useQuery(null));
166
+ test("handles null query from accessor", () => {
167
+ const { result } = renderHook(() => useQuery(() => null, []));
168
168
 
169
169
  expect(result.current.loading).toBe(false);
170
170
  expect(result.current.data).toBe(null);
171
171
  expect(result.current.error).toBe(null);
172
172
  });
173
173
 
174
- test("handles undefined query", () => {
175
- const { result } = renderHook(() => useQuery(undefined));
174
+ test("handles undefined query from accessor", () => {
175
+ const { result } = renderHook(() => useQuery(() => undefined, []));
176
176
 
177
177
  expect(result.current.loading).toBe(false);
178
178
  expect(result.current.data).toBe(null);
179
179
  expect(result.current.error).toBe(null);
180
180
  });
181
181
 
182
- test("handles accessor function returning query", async () => {
183
- const mockQuery = createMockQueryResult<{ id: string; name: string }>();
184
- const accessor = () => mockQuery;
185
-
186
- const { result } = renderHook(() => useQuery(accessor));
187
-
188
- act(() => {
189
- mockQuery._setValue({ id: "123", name: "John" });
190
- });
191
-
192
- await waitFor(() => {
193
- expect(result.current.data).toEqual({ id: "123", name: "John" });
194
- });
195
- });
196
-
197
- test("handles accessor function returning null", () => {
198
- const accessor = () => null;
199
-
200
- const { result } = renderHook(() => useQuery(accessor));
201
-
202
- expect(result.current.loading).toBe(false);
203
- expect(result.current.data).toBe(null);
204
- });
205
-
206
- test("handles accessor function returning undefined", () => {
207
- const accessor = () => undefined;
208
-
209
- const { result } = renderHook(() => useQuery(accessor));
210
-
211
- expect(result.current.loading).toBe(false);
212
- expect(result.current.data).toBe(null);
213
- });
214
-
215
182
  test("updates when query subscription emits", async () => {
216
183
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
217
184
 
218
- const { result } = renderHook(() => useQuery(mockQuery));
185
+ const { result } = renderHook(() => useQuery(() => mockQuery, []));
219
186
 
220
187
  // First value
221
188
  act(() => {
@@ -239,7 +206,7 @@ describe("useQuery", () => {
239
206
  test("refetch reloads the query", async () => {
240
207
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
241
208
 
242
- const { result } = renderHook(() => useQuery(mockQuery));
209
+ const { result } = renderHook(() => useQuery(() => mockQuery, []));
243
210
 
244
211
  // Initial load
245
212
  act(() => {
@@ -267,7 +234,7 @@ describe("useQuery", () => {
267
234
  test("refetch handles errors", async () => {
268
235
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
269
236
 
270
- const { result } = renderHook(() => useQuery(mockQuery));
237
+ const { result } = renderHook(() => useQuery(() => mockQuery, []));
271
238
 
272
239
  // Initial load succeeds
273
240
  act(() => {
@@ -287,7 +254,7 @@ describe("useQuery", () => {
287
254
  } as unknown as QueryResult<{ id: string; name: string }>;
288
255
 
289
256
  // Update the query to use failing query
290
- const { result: result2 } = renderHook(() => useQuery(failingQuery));
257
+ const { result: result2 } = renderHook(() => useQuery(() => failingQuery, []));
291
258
 
292
259
  await waitFor(() => {
293
260
  expect(result2.current.error?.message).toBe("Refetch failed");
@@ -295,7 +262,7 @@ describe("useQuery", () => {
295
262
  });
296
263
 
297
264
  test("refetch does nothing when query is null", () => {
298
- const { result } = renderHook(() => useQuery(null));
265
+ const { result } = renderHook(() => useQuery(() => null, []));
299
266
 
300
267
  act(() => {
301
268
  result.current.refetch();
@@ -308,7 +275,7 @@ describe("useQuery", () => {
308
275
  test("refetch does nothing when skip is true", () => {
309
276
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
310
277
 
311
- const { result } = renderHook(() => useQuery(mockQuery, { skip: true }));
278
+ const { result } = renderHook(() => useQuery(() => mockQuery, [], { skip: true }));
312
279
 
313
280
  act(() => {
314
281
  result.current.refetch();
@@ -330,7 +297,7 @@ describe("useQuery", () => {
330
297
  },
331
298
  } as unknown as QueryResult<{ id: string; name: string }>;
332
299
 
333
- const { result } = renderHook(() => useQuery(mockQuery));
300
+ const { result } = renderHook(() => useQuery(() => mockQuery, []));
334
301
 
335
302
  await waitFor(() => {
336
303
  expect(result.current.data).toEqual({ id: "123", name: "John" });
@@ -362,7 +329,7 @@ describe("useQuery", () => {
362
329
  };
363
330
  };
364
331
 
365
- const { unmount } = renderHook(() => useQuery(mockQuery));
332
+ const { unmount } = renderHook(() => useQuery(() => mockQuery, []));
366
333
 
367
334
  unmount();
368
335
 
@@ -372,7 +339,7 @@ describe("useQuery", () => {
372
339
  test("does not update state after unmount", async () => {
373
340
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
374
341
 
375
- const { unmount } = renderHook(() => useQuery(mockQuery));
342
+ const { unmount } = renderHook(() => useQuery(() => mockQuery, []));
376
343
 
377
344
  // Unmount before query resolves
378
345
  unmount();
@@ -387,12 +354,12 @@ describe("useQuery", () => {
387
354
  expect(true).toBe(true);
388
355
  });
389
356
 
390
- test("handles query change", async () => {
357
+ test("handles query change via deps", async () => {
391
358
  const mockQuery1 = createMockQueryResult<{ id: string; name: string }>();
392
359
  const mockQuery2 = createMockQueryResult<{ id: string; name: string }>();
393
360
 
394
- let currentQuery = mockQuery1;
395
- const { result, rerender } = renderHook(() => useQuery(currentQuery));
361
+ let queryId = 1;
362
+ const { result, rerender } = renderHook(() => useQuery(() => (queryId === 1 ? mockQuery1 : mockQuery2), [queryId]));
396
363
 
397
364
  // Load first query
398
365
  act(() => {
@@ -404,7 +371,7 @@ describe("useQuery", () => {
404
371
  });
405
372
 
406
373
  // Change to second query
407
- currentQuery = mockQuery2;
374
+ queryId = 2;
408
375
  rerender();
409
376
 
410
377
  expect(result.current.loading).toBe(true);
@@ -423,7 +390,7 @@ describe("useQuery", () => {
423
390
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
424
391
 
425
392
  let skip = true;
426
- const { result, rerender } = renderHook(() => useQuery(mockQuery, { skip }));
393
+ const { result, rerender } = renderHook(() => useQuery(() => mockQuery, [], { skip }));
427
394
 
428
395
  expect(result.current.loading).toBe(false);
429
396
 
@@ -446,7 +413,7 @@ describe("useQuery", () => {
446
413
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
447
414
 
448
415
  let skip = false;
449
- const { result, rerender } = renderHook(() => useQuery(mockQuery, { skip }));
416
+ const { result, rerender } = renderHook(() => useQuery(() => mockQuery, [], { skip }));
450
417
 
451
418
  act(() => {
452
419
  mockQuery._setValue({ id: "123", name: "John" });
@@ -464,6 +431,46 @@ describe("useQuery", () => {
464
431
  expect(result.current.data).toBe(null);
465
432
  expect(result.current.error).toBe(null);
466
433
  });
434
+
435
+ test("select transforms the data", async () => {
436
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
437
+
438
+ const { result } = renderHook(() =>
439
+ useQuery(() => mockQuery, [], {
440
+ select: (data) => data.name.toUpperCase(),
441
+ }),
442
+ );
443
+
444
+ act(() => {
445
+ mockQuery._setValue({ id: "123", name: "John" });
446
+ });
447
+
448
+ await waitFor(() => {
449
+ expect(result.current.data).toBe("JOHN");
450
+ });
451
+ });
452
+
453
+ test("Route + Params pattern works", async () => {
454
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
455
+ const route = (_params: { id: string }) => mockQuery;
456
+
457
+ const { result } = renderHook(() => useQuery(route, { id: "123" }));
458
+
459
+ act(() => {
460
+ mockQuery._setValue({ id: "123", name: "John" });
461
+ });
462
+
463
+ await waitFor(() => {
464
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
465
+ });
466
+ });
467
+
468
+ test("Route + Params with null route", () => {
469
+ const { result } = renderHook(() => useQuery(null, { id: "123" }));
470
+
471
+ expect(result.current.loading).toBe(false);
472
+ expect(result.current.data).toBe(null);
473
+ });
467
474
  });
468
475
 
469
476
  // =============================================================================
@@ -657,14 +664,14 @@ describe("useMutation", () => {
657
664
  });
658
665
 
659
666
  // =============================================================================
660
- // Tests: useLazyQuery
667
+ // Tests: useLazyQuery (Accessor + Deps pattern)
661
668
  // =============================================================================
662
669
 
663
670
  describe("useLazyQuery", () => {
664
671
  test("does not execute query on mount", () => {
665
672
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
666
673
 
667
- const { result } = renderHook(() => useLazyQuery(mockQuery));
674
+ const { result } = renderHook(() => useLazyQuery(() => mockQuery, []));
668
675
 
669
676
  expect(result.current.loading).toBe(false);
670
677
  expect(result.current.data).toBe(null);
@@ -676,7 +683,7 @@ describe("useLazyQuery", () => {
676
683
  name: "John",
677
684
  });
678
685
 
679
- const { result } = renderHook(() => useLazyQuery(mockQuery));
686
+ const { result } = renderHook(() => useLazyQuery(() => mockQuery, []));
680
687
 
681
688
  let queryResult: { id: string; name: string } | undefined;
682
689
  await act(async () => {
@@ -691,7 +698,7 @@ describe("useLazyQuery", () => {
691
698
  // Create a mock query that rejects
692
699
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
693
700
 
694
- const { result } = renderHook(() => useLazyQuery(mockQuery));
701
+ const { result } = renderHook(() => useLazyQuery(() => mockQuery, []));
695
702
 
696
703
  // Set error before execute
697
704
  act(() => {
@@ -717,7 +724,7 @@ describe("useLazyQuery", () => {
717
724
  },
718
725
  } as unknown as QueryResult<{ id: string }>;
719
726
 
720
- const { result } = renderHook(() => useLazyQuery(mockQuery));
727
+ const { result } = renderHook(() => useLazyQuery(() => mockQuery, []));
721
728
 
722
729
  await act(async () => {
723
730
  try {
@@ -736,7 +743,7 @@ describe("useLazyQuery", () => {
736
743
  name: "John",
737
744
  });
738
745
 
739
- const { result } = renderHook(() => useLazyQuery(mockQuery));
746
+ const { result } = renderHook(() => useLazyQuery(() => mockQuery, []));
740
747
 
741
748
  await act(async () => {
742
749
  await result.current.execute();
@@ -753,8 +760,8 @@ describe("useLazyQuery", () => {
753
760
  expect(result.current.loading).toBe(false);
754
761
  });
755
762
 
756
- test("handles null query", async () => {
757
- const { result } = renderHook(() => useLazyQuery(null));
763
+ test("handles null query from accessor", async () => {
764
+ const { result } = renderHook(() => useLazyQuery(() => null, []));
758
765
 
759
766
  let queryResult: any;
760
767
  await act(async () => {
@@ -766,8 +773,8 @@ describe("useLazyQuery", () => {
766
773
  expect(result.current.loading).toBe(false);
767
774
  });
768
775
 
769
- test("handles undefined query", async () => {
770
- const { result } = renderHook(() => useLazyQuery(undefined));
776
+ test("handles undefined query from accessor", async () => {
777
+ const { result } = renderHook(() => useLazyQuery(() => undefined, []));
771
778
 
772
779
  let queryResult: any;
773
780
  await act(async () => {
@@ -779,36 +786,6 @@ describe("useLazyQuery", () => {
779
786
  expect(result.current.loading).toBe(false);
780
787
  });
781
788
 
782
- test("handles accessor function returning query", async () => {
783
- const mockQuery = createMockQueryResult<{ id: string; name: string }>({
784
- id: "123",
785
- name: "John",
786
- });
787
- const accessor = () => mockQuery;
788
-
789
- const { result } = renderHook(() => useLazyQuery(accessor));
790
-
791
- let queryResult: { id: string; name: string } | undefined;
792
- await act(async () => {
793
- queryResult = await result.current.execute();
794
- });
795
-
796
- expect(queryResult).toEqual({ id: "123", name: "John" });
797
- });
798
-
799
- test("handles accessor function returning null", async () => {
800
- const accessor = () => null;
801
-
802
- const { result } = renderHook(() => useLazyQuery(accessor));
803
-
804
- let queryResult: any;
805
- await act(async () => {
806
- queryResult = await result.current.execute();
807
- });
808
-
809
- expect(queryResult).toBe(null);
810
- });
811
-
812
789
  test("uses latest query value from accessor on execute", async () => {
813
790
  let currentValue = "first";
814
791
  const mockQuery1 = createMockQueryResult<string>("first");
@@ -816,7 +793,7 @@ describe("useLazyQuery", () => {
816
793
 
817
794
  const accessor = () => (currentValue === "first" ? mockQuery1 : mockQuery2);
818
795
 
819
- const { result } = renderHook(() => useLazyQuery(accessor));
796
+ const { result } = renderHook(() => useLazyQuery(accessor, []));
820
797
 
821
798
  // First execute
822
799
  let queryResult1: string | undefined;
@@ -841,7 +818,7 @@ describe("useLazyQuery", () => {
841
818
  test("shows loading state during execution", async () => {
842
819
  const mockQuery = createMockQueryResult<{ id: string }>();
843
820
 
844
- const { result } = renderHook(() => useLazyQuery(mockQuery));
821
+ const { result } = renderHook(() => useLazyQuery(() => mockQuery, []));
845
822
 
846
823
  // Execute and set value
847
824
  let executePromise: Promise<{ id: string }>;
@@ -858,7 +835,7 @@ describe("useLazyQuery", () => {
858
835
  test("does not update state after unmount", async () => {
859
836
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
860
837
 
861
- const { result, unmount } = renderHook(() => useLazyQuery(mockQuery));
838
+ const { result, unmount } = renderHook(() => useLazyQuery(() => mockQuery, []));
862
839
 
863
840
  // Start execution, unmount, then resolve
864
841
  const executePromise = result.current.execute();
@@ -867,72 +844,83 @@ describe("useLazyQuery", () => {
867
844
  // Resolve after unmount
868
845
  await act(async () => {
869
846
  mockQuery._setValue({ id: "123", name: "John" });
870
- await executePromise;
847
+ try {
848
+ await executePromise;
849
+ } catch {
850
+ // May reject due to unmount
851
+ }
871
852
  });
872
853
 
873
854
  // Test passes if no error is thrown (state update after unmount would cause error)
874
855
  expect(true).toBe(true);
875
856
  });
876
857
 
877
- test("clears error on successful execute after previous error", async () => {
878
- const mockQuery1 = createMockQueryResult<{ id: string }>();
879
- const mockQuery2 = createMockQueryResult<{ id: string }>({ id: "123" });
858
+ test("can execute multiple times", async () => {
859
+ let count = 0;
860
+ const createQuery = () => {
861
+ count++;
862
+ return createMockQueryResult<{ count: number }>({ count });
863
+ };
880
864
 
881
- const { result, rerender } = renderHook(({ query }) => useLazyQuery(query), {
882
- initialProps: { query: mockQuery1 },
883
- });
865
+ const { result } = renderHook(() => useLazyQuery(() => createQuery(), []));
884
866
 
885
- // First execution fails
867
+ // First execution
886
868
  await act(async () => {
887
- const executePromise = result.current.execute();
888
- mockQuery1._setError(new Error("Query failed"));
889
- try {
890
- await executePromise;
891
- } catch {
892
- // Expected error
893
- }
869
+ await result.current.execute();
894
870
  });
895
871
 
896
- expect(result.current.error?.message).toBe("Query failed");
897
-
898
- // Switch to successful query
899
- rerender({ query: mockQuery2 });
872
+ expect(result.current.data?.count).toBe(1);
900
873
 
901
- // Second execution succeeds
874
+ // Second execution
902
875
  await act(async () => {
903
876
  await result.current.execute();
904
877
  });
905
878
 
906
- expect(result.current.error).toBe(null);
907
- expect(result.current.data).toEqual({ id: "123" });
879
+ expect(result.current.data?.count).toBe(2);
908
880
  });
909
881
 
910
- test("can execute multiple times", async () => {
911
- const mockQuery1 = createMockQueryResult<{ count: number }>();
912
- const mockQuery2 = createMockQueryResult<{ count: number }>();
882
+ test("Route + Params pattern works", async () => {
883
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>({
884
+ id: "123",
885
+ name: "John",
886
+ });
887
+ const route = (_params: { id: string }) => mockQuery;
888
+
889
+ const { result } = renderHook(() => useLazyQuery(route, { id: "123" }));
913
890
 
914
- const { result, rerender } = renderHook(({ query }) => useLazyQuery(query), {
915
- initialProps: { query: mockQuery1 },
891
+ await act(async () => {
892
+ await result.current.execute();
916
893
  });
917
894
 
918
- // First execution
895
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
896
+ });
897
+
898
+ test("Route + Params with null route", async () => {
899
+ const { result } = renderHook(() => useLazyQuery(null, { id: "123" }));
900
+
919
901
  await act(async () => {
920
- const executePromise = result.current.execute();
921
- mockQuery1._setValue({ count: 1 });
922
- await executePromise;
902
+ await result.current.execute();
923
903
  });
924
904
 
925
- expect(result.current.data?.count).toBe(1);
905
+ expect(result.current.data).toBe(null);
906
+ });
926
907
 
927
- // Change to second query and execute again
928
- rerender({ query: mockQuery2 });
908
+ test("select transforms the data", async () => {
909
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>({
910
+ id: "123",
911
+ name: "John",
912
+ });
913
+
914
+ const { result } = renderHook(() =>
915
+ useLazyQuery(() => mockQuery, [], {
916
+ select: (data) => data.name.toUpperCase(),
917
+ }),
918
+ );
929
919
 
930
920
  await act(async () => {
931
- const executePromise = result.current.execute();
932
- mockQuery2._setValue({ count: 2 });
933
- await executePromise;
921
+ await result.current.execute();
934
922
  });
935
923
 
936
- expect(result.current.data?.count).toBe(2);
924
+ expect(result.current.data).toBe("JOHN");
937
925
  });
938
926
  });
package/src/hooks.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * function UserProfile({ userId }: { userId: string }) {
12
12
  * const client = useLensClient();
13
- * const { data: user, loading } = useQuery(client.user.get({ id: userId }));
13
+ * // Route + Params pattern (recommended)
14
+ * const { data: user, loading } = useQuery(client.user.get, { id: userId });
14
15
  * if (loading) return <Spinner />;
15
16
  * return <h1>{user?.name}</h1>;
16
17
  * }
@@ -25,18 +26,7 @@
25
26
  */
26
27
 
27
28
  import type { MutationResult, QueryResult } from "@sylphx/lens-client";
28
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
29
-
30
- // =============================================================================
31
- // Query Input Types
32
- // =============================================================================
33
-
34
- /** Query input - can be a query, null/undefined, or an accessor function */
35
- export type QueryInput<T> =
36
- | QueryResult<T>
37
- | null
38
- | undefined
39
- | (() => QueryResult<T> | null | undefined);
29
+ import { type DependencyList, useCallback, useEffect, useMemo, useRef, useState } from "react";
40
30
 
41
31
  // =============================================================================
42
32
  // Types
@@ -69,75 +59,144 @@ export interface UseMutationResult<TInput, TOutput> {
69
59
  }
70
60
 
71
61
  /** Options for useQuery */
72
- export interface UseQueryOptions {
62
+ export interface UseQueryOptions<TData = unknown, TSelected = TData> {
73
63
  /** Skip the query (don't execute) */
74
64
  skip?: boolean;
65
+ /** Transform the query result */
66
+ select?: (data: TData) => TSelected;
75
67
  }
76
68
 
69
+ /** Route function type - takes params and returns QueryResult */
70
+ export type RouteFunction<TParams, TResult> = (params: TParams) => QueryResult<TResult>;
71
+
72
+ /** Accessor function type - returns QueryResult or null */
73
+ export type QueryAccessor<T> = () => QueryResult<T> | null | undefined;
74
+
75
+ /** Mutation function type */
76
+ export type MutationFn<TInput, TOutput> = (input: TInput) => Promise<MutationResult<TOutput>>;
77
+
77
78
  // =============================================================================
78
- // useQuery Hook
79
+ // useQuery Hook - Route + Params (Primary API)
79
80
  // =============================================================================
80
81
 
81
- /** Helper to resolve query input (handles accessor functions) */
82
- function resolveQuery<T>(input: QueryInput<T>): QueryResult<T> | null | undefined {
83
- return typeof input === "function" ? input() : input;
84
- }
85
-
86
82
  /**
87
- * Subscribe to a query with reactive updates
83
+ * Subscribe to a query with reactive updates.
84
+ *
85
+ * Two usage patterns:
88
86
  *
89
- * @param queryInput - QueryResult, null/undefined, or accessor function returning QueryResult
90
- * @param options - Query options
87
+ * **1. Route + Params (recommended)** - Stable references, no infinite loops
88
+ * ```tsx
89
+ * const { data } = useQuery(client.user.get, { id: userId });
90
+ * ```
91
+ *
92
+ * **2. Accessor + Deps (escape hatch)** - For complex/composed queries
93
+ * ```tsx
94
+ * const { data } = useQuery(() => client.user.get({ id }).pipe(transform), [id]);
95
+ * ```
91
96
  *
92
97
  * @example
93
98
  * ```tsx
94
- * // Basic usage
99
+ * // Basic usage - Route + Params
95
100
  * function UserProfile({ userId }: { userId: string }) {
96
101
  * const client = useLensClient();
97
- * const { data: user, loading, error } = useQuery(client.user.get({ id: userId }));
102
+ * const { data: user, loading, error } = useQuery(client.user.get, { id: userId });
98
103
  *
99
104
  * if (loading) return <Spinner />;
100
105
  * if (error) return <Error message={error.message} />;
101
- * if (!user) return <NotFound />;
106
+ * return <h1>{user?.name}</h1>;
107
+ * }
102
108
  *
103
- * return <h1>{user.name}</h1>;
109
+ * // With select transform
110
+ * function UserName({ userId }: { userId: string }) {
111
+ * const client = useLensClient();
112
+ * const { data: name } = useQuery(client.user.get, { id: userId }, {
113
+ * select: (user) => user.name
114
+ * });
115
+ * return <span>{name}</span>;
104
116
  * }
105
117
  *
106
- * // Conditional query (null when condition not met)
118
+ * // Conditional query
107
119
  * function SessionInfo({ sessionId }: { sessionId: string | null }) {
108
120
  * const client = useLensClient();
109
121
  * const { data } = useQuery(
110
- * sessionId ? client.session.get({ id: sessionId }) : null
122
+ * sessionId ? client.session.get : null,
123
+ * { id: sessionId ?? '' }
111
124
  * );
112
- * // data is null when sessionId is null
113
125
  * return <span>{data?.totalTokens}</span>;
114
126
  * }
115
127
  *
116
- * // Accessor function (reactive inputs)
117
- * function ReactiveQuery({ sessionId }: { sessionId: Signal<string | null> }) {
128
+ * // Skip query
129
+ * function ConditionalQuery({ userId, shouldFetch }: { userId: string; shouldFetch: boolean }) {
118
130
  * const client = useLensClient();
119
- * const { data } = useQuery(() =>
120
- * sessionId.value ? client.session.get({ id: sessionId.value }) : null
121
- * );
122
- * return <span>{data?.totalTokens}</span>;
131
+ * const { data } = useQuery(client.user.get, { id: userId }, { skip: !shouldFetch });
123
132
  * }
124
133
  *
125
- * // Skip query conditionally
126
- * function ConditionalQuery({ shouldFetch }: { shouldFetch: boolean }) {
134
+ * // Complex queries with accessor (escape hatch)
135
+ * function ComplexQuery({ userId }: { userId: string }) {
127
136
  * const client = useLensClient();
128
- * const { data } = useQuery(client.user.list(), { skip: !shouldFetch });
137
+ * const { data } = useQuery(
138
+ * () => client.user.get({ id: userId }),
139
+ * [userId]
140
+ * );
129
141
  * }
130
142
  * ```
131
143
  */
132
- export function useQuery<T>(
133
- queryInput: QueryInput<T>,
134
- options?: UseQueryOptions,
135
- ): UseQueryResult<T> {
136
- // Resolve query (handles accessor functions)
137
- const query = useMemo(() => resolveQuery(queryInput), [queryInput]);
138
-
139
- const [data, setData] = useState<T | null>(null);
140
- const [loading, setLoading] = useState(!options?.skip && query != null);
144
+
145
+ // Overload 1: Route + Params (recommended)
146
+ export function useQuery<TParams, TResult, TSelected = TResult>(
147
+ route: RouteFunction<TParams, TResult> | null,
148
+ params: TParams,
149
+ options?: UseQueryOptions<TResult, TSelected>,
150
+ ): UseQueryResult<TSelected>;
151
+
152
+ // Overload 2: Accessor + Deps (escape hatch for complex queries)
153
+ export function useQuery<TResult, TSelected = TResult>(
154
+ accessor: QueryAccessor<TResult>,
155
+ deps: DependencyList,
156
+ options?: UseQueryOptions<TResult, TSelected>,
157
+ ): UseQueryResult<TSelected>;
158
+
159
+ // Implementation
160
+ export function useQuery<TParams, TResult, TSelected = TResult>(
161
+ routeOrAccessor: RouteFunction<TParams, TResult> | QueryAccessor<TResult> | null,
162
+ paramsOrDeps: TParams | DependencyList,
163
+ options?: UseQueryOptions<TResult, TSelected>,
164
+ ): UseQueryResult<TSelected> {
165
+ // Detect which overload is being used
166
+ const isAccessorMode = Array.isArray(paramsOrDeps);
167
+
168
+ // Stable params key for Route + Params mode
169
+ const paramsKey = !isAccessorMode ? JSON.stringify(paramsOrDeps) : null;
170
+
171
+ // Create query - memoized based on route/params or deps
172
+ const query = useMemo(
173
+ () => {
174
+ if (options?.skip) return null;
175
+
176
+ if (isAccessorMode) {
177
+ // Accessor mode: call the function
178
+ const accessor = routeOrAccessor as QueryAccessor<TResult>;
179
+ return accessor();
180
+ }
181
+ // Route + Params mode
182
+ if (!routeOrAccessor) return null;
183
+ const route = routeOrAccessor as RouteFunction<TParams, TResult>;
184
+ return route(paramsOrDeps as TParams);
185
+ },
186
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Dynamic deps based on overload mode - intentional
187
+ isAccessorMode
188
+ ? // eslint-disable-next-line react-hooks/exhaustive-deps
189
+ [options?.skip, ...(paramsOrDeps as DependencyList)]
190
+ : // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ [routeOrAccessor, paramsKey, options?.skip],
192
+ );
193
+
194
+ // Use ref for select to avoid it being a dependency
195
+ const selectRef = useRef(options?.select);
196
+ selectRef.current = options?.select;
197
+
198
+ const [data, setData] = useState<TSelected | null>(null);
199
+ const [loading, setLoading] = useState(query != null && !options?.skip);
141
200
  const [error, setError] = useState<Error | null>(null);
142
201
 
143
202
  // Track mounted state
@@ -147,12 +206,17 @@ export function useQuery<T>(
147
206
  const queryRef = useRef(query);
148
207
  queryRef.current = query;
149
208
 
209
+ // Transform helper
210
+ const transform = useCallback((value: TResult): TSelected => {
211
+ return selectRef.current ? selectRef.current(value) : (value as unknown as TSelected);
212
+ }, []);
213
+
150
214
  // Subscribe to query
151
215
  useEffect(() => {
152
216
  mountedRef.current = true;
153
217
 
154
218
  // Handle null/undefined query
155
- if (query == null || options?.skip) {
219
+ if (query == null) {
156
220
  setData(null);
157
221
  setLoading(false);
158
222
  setError(null);
@@ -165,7 +229,7 @@ export function useQuery<T>(
165
229
  // Subscribe to updates
166
230
  const unsubscribe = query.subscribe((value) => {
167
231
  if (mountedRef.current) {
168
- setData(value);
232
+ setData(transform(value));
169
233
  setLoading(false);
170
234
  }
171
235
  });
@@ -174,7 +238,7 @@ export function useQuery<T>(
174
238
  query.then(
175
239
  (value) => {
176
240
  if (mountedRef.current) {
177
- setData(value);
241
+ setData(transform(value));
178
242
  setLoading(false);
179
243
  }
180
244
  },
@@ -190,12 +254,12 @@ export function useQuery<T>(
190
254
  mountedRef.current = false;
191
255
  unsubscribe();
192
256
  };
193
- }, [query, options?.skip]);
257
+ }, [query, transform]);
194
258
 
195
259
  // Refetch function
196
260
  const refetch = useCallback(() => {
197
261
  const currentQuery = queryRef.current;
198
- if (currentQuery == null || options?.skip) return;
262
+ if (currentQuery == null) return;
199
263
 
200
264
  setLoading(true);
201
265
  setError(null);
@@ -203,7 +267,7 @@ export function useQuery<T>(
203
267
  currentQuery.then(
204
268
  (value) => {
205
269
  if (mountedRef.current) {
206
- setData(value);
270
+ setData(transform(value));
207
271
  setLoading(false);
208
272
  }
209
273
  },
@@ -214,7 +278,7 @@ export function useQuery<T>(
214
278
  }
215
279
  },
216
280
  );
217
- }, [options?.skip]);
281
+ }, [transform]);
218
282
 
219
283
  return { data, loading, error, refetch };
220
284
  }
@@ -223,9 +287,6 @@ export function useQuery<T>(
223
287
  // useMutation Hook
224
288
  // =============================================================================
225
289
 
226
- /** Mutation function type */
227
- export type MutationFn<TInput, TOutput> = (input: TInput) => Promise<MutationResult<TOutput>>;
228
-
229
290
  /**
230
291
  * Execute mutations with loading/error state
231
292
  *
@@ -348,57 +409,77 @@ export interface UseLazyQueryResult<T> {
348
409
  /**
349
410
  * Execute a query on demand (not on mount)
350
411
  *
351
- * @param queryInput - QueryResult, null/undefined, or accessor function returning QueryResult
352
- *
353
412
  * @example
354
413
  * ```tsx
414
+ * // Route + Params pattern
355
415
  * function SearchUsers() {
356
416
  * const client = useLensClient();
357
417
  * const [searchTerm, setSearchTerm] = useState('');
358
418
  * const { execute, data, loading } = useLazyQuery(
359
- * client.user.search({ query: searchTerm })
419
+ * client.user.search,
420
+ * { query: searchTerm }
360
421
  * );
361
422
  *
362
- * const handleSearch = async () => {
363
- * const users = await execute();
364
- * console.log('Found:', users);
365
- * };
366
- *
367
423
  * return (
368
424
  * <div>
369
- * <input
370
- * value={searchTerm}
371
- * onChange={e => setSearchTerm(e.target.value)}
372
- * />
373
- * <button onClick={handleSearch} disabled={loading}>
374
- * Search
375
- * </button>
425
+ * <input value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
426
+ * <button onClick={execute} disabled={loading}>Search</button>
376
427
  * {data?.map(user => <UserCard key={user.id} user={user} />)}
377
428
  * </div>
378
429
  * );
379
430
  * }
380
431
  *
381
- * // With accessor function
382
- * function LazyReactiveQuery({ sessionId }: { sessionId: Signal<string | null> }) {
432
+ * // Accessor pattern
433
+ * function LazyComplexQuery({ userId }: { userId: string }) {
383
434
  * const client = useLensClient();
384
- * const { execute, data } = useLazyQuery(() =>
385
- * sessionId.value ? client.session.get({ id: sessionId.value }) : null
435
+ * const { execute, data } = useLazyQuery(
436
+ * () => client.user.get({ id: userId }),
437
+ * [userId]
386
438
  * );
387
439
  * return <button onClick={execute}>Load</button>;
388
440
  * }
389
441
  * ```
390
442
  */
391
- export function useLazyQuery<T>(queryInput: QueryInput<T>): UseLazyQueryResult<T> {
392
- const [data, setData] = useState<T | null>(null);
443
+
444
+ // Overload 1: Route + Params
445
+ export function useLazyQuery<TParams, TResult, TSelected = TResult>(
446
+ route: RouteFunction<TParams, TResult> | null,
447
+ params: TParams,
448
+ options?: UseQueryOptions<TResult, TSelected>,
449
+ ): UseLazyQueryResult<TSelected>;
450
+
451
+ // Overload 2: Accessor + Deps
452
+ export function useLazyQuery<TResult, TSelected = TResult>(
453
+ accessor: QueryAccessor<TResult>,
454
+ deps: DependencyList,
455
+ options?: UseQueryOptions<TResult, TSelected>,
456
+ ): UseLazyQueryResult<TSelected>;
457
+
458
+ // Implementation
459
+ export function useLazyQuery<TParams, TResult, TSelected = TResult>(
460
+ routeOrAccessor: RouteFunction<TParams, TResult> | QueryAccessor<TResult> | null,
461
+ paramsOrDeps: TParams | DependencyList,
462
+ options?: UseQueryOptions<TResult, TSelected>,
463
+ ): UseLazyQueryResult<TSelected> {
464
+ const [data, setData] = useState<TSelected | null>(null);
393
465
  const [loading, setLoading] = useState(false);
394
466
  const [error, setError] = useState<Error | null>(null);
395
467
 
396
468
  // Track mounted state
397
469
  const mountedRef = useRef(true);
398
470
 
399
- // Store queryInput ref for execute (so it uses latest value)
400
- const queryInputRef = useRef(queryInput);
401
- queryInputRef.current = queryInput;
471
+ // Detect which overload
472
+ const isAccessorMode = Array.isArray(paramsOrDeps);
473
+
474
+ // Store refs for execute (so it uses latest values)
475
+ const routeOrAccessorRef = useRef(routeOrAccessor);
476
+ routeOrAccessorRef.current = routeOrAccessor;
477
+
478
+ const paramsOrDepsRef = useRef(paramsOrDeps);
479
+ paramsOrDepsRef.current = paramsOrDeps;
480
+
481
+ const selectRef = useRef(options?.select);
482
+ selectRef.current = options?.select;
402
483
 
403
484
  useEffect(() => {
404
485
  mountedRef.current = true;
@@ -408,13 +489,23 @@ export function useLazyQuery<T>(queryInput: QueryInput<T>): UseLazyQueryResult<T
408
489
  }, []);
409
490
 
410
491
  // Execute function
411
- const execute = useCallback(async (): Promise<T> => {
412
- const query = resolveQuery(queryInputRef.current);
492
+ const execute = useCallback(async (): Promise<TSelected> => {
493
+ let query: QueryResult<TResult> | null | undefined;
494
+
495
+ if (isAccessorMode) {
496
+ const accessor = routeOrAccessorRef.current as QueryAccessor<TResult>;
497
+ query = accessor();
498
+ } else {
499
+ const route = routeOrAccessorRef.current as RouteFunction<TParams, TResult> | null;
500
+ if (route) {
501
+ query = route(paramsOrDepsRef.current as TParams);
502
+ }
503
+ }
413
504
 
414
505
  if (query == null) {
415
506
  setData(null);
416
507
  setLoading(false);
417
- return null as T;
508
+ return null as TSelected;
418
509
  }
419
510
 
420
511
  setLoading(true);
@@ -422,12 +513,15 @@ export function useLazyQuery<T>(queryInput: QueryInput<T>): UseLazyQueryResult<T
422
513
 
423
514
  try {
424
515
  const result = await query;
516
+ const selected = selectRef.current
517
+ ? selectRef.current(result)
518
+ : (result as unknown as TSelected);
425
519
 
426
520
  if (mountedRef.current) {
427
- setData(result);
521
+ setData(selected);
428
522
  }
429
523
 
430
- return result;
524
+ return selected;
431
525
  } catch (err) {
432
526
  const queryError = err instanceof Error ? err : new Error(String(err));
433
527
  if (mountedRef.current) {
@@ -439,7 +533,7 @@ export function useLazyQuery<T>(queryInput: QueryInput<T>): UseLazyQueryResult<T
439
533
  setLoading(false);
440
534
  }
441
535
  }
442
- }, []);
536
+ }, [isAccessorMode]);
443
537
 
444
538
  // Reset function
445
539
  const reset = useCallback(() => {
package/src/index.ts CHANGED
@@ -18,7 +18,8 @@ export { LensProvider, type LensProviderProps, useLensClient } from "./context.j
18
18
  export {
19
19
  type MutationFn,
20
20
  // Types
21
- type QueryInput,
21
+ type QueryAccessor,
22
+ type RouteFunction,
22
23
  type UseLazyQueryResult,
23
24
  type UseMutationResult,
24
25
  type UseQueryOptions,