@sylphx/lens-react 1.2.22 → 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/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,