@effector-tanstack-query/react 0.3.1 → 0.5.0

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/index.ts CHANGED
@@ -8,13 +8,20 @@ import type {
8
8
  FetchStatus,
9
9
  HydrateOptions,
10
10
  MutateOptions,
11
+ MutationFilters,
12
+ QueryFilters,
11
13
  QueryStatus,
12
14
  } from '@tanstack/query-core'
15
+
16
+ // Re-exported for convenience so consumers can type filter arguments without a
17
+ // direct `@tanstack/query-core` import.
18
+ export type { QueryFilters, MutationFilters } from '@tanstack/query-core'
13
19
  import { $queryClient } from '@effector-tanstack-query/core'
14
20
  import type {
15
21
  InfiniteQueryResult,
16
22
  MutationResult,
17
23
  MutationStatus,
24
+ QueriesResult,
18
25
  QueryResult,
19
26
  } from '@effector-tanstack-query/core'
20
27
 
@@ -166,6 +173,62 @@ export function useMutation<TData = unknown, TError = Error, TVariables = void>(
166
173
  return { ...state, mutate, mutateWith, reset }
167
174
  }
168
175
 
176
+ // =============================================================================
177
+ // useIsFetching / useIsMutating — global in-flight counters for the scope's
178
+ // QueryClient. Thin React-subscription wrappers over the QueryClient's own
179
+ // `isFetching()` / `isMutating()` — the counting is done by tanstack, not here.
180
+ // =============================================================================
181
+
182
+ /**
183
+ * Number of queries currently fetching in the scope's `QueryClient`. Use for
184
+ * global indicators — top-bar spinners, tray badges, etc.
185
+ *
186
+ * Pass `QueryFilters` to scope the count (e.g. `{ queryKey: ['user'] }`).
187
+ * Filters are applied by value on every notification, so passing a fresh
188
+ * object literal each render is fine — no `useMemo` needed.
189
+ *
190
+ * Resolves the client via `useUnit($queryClient)`, so each fork scope counts
191
+ * its own in-flight queries. Returns `0` when no client is set (e.g. an
192
+ * RSC/SSR pass with no scope client) instead of throwing.
193
+ */
194
+ export function useIsFetching(filters?: QueryFilters): number {
195
+ const qc = useUnit($queryClient)
196
+ // Keep filters out of the subscribe dependency so a fresh literal each render
197
+ // doesn't re-subscribe; getSnapshot reads the latest via the ref.
198
+ const filtersRef = React.useRef(filters)
199
+ filtersRef.current = filters
200
+
201
+ return React.useSyncExternalStore(
202
+ React.useCallback(
203
+ (notify) => qc?.getQueryCache().subscribe(notify) ?? (() => {}),
204
+ [qc],
205
+ ),
206
+ () => qc?.isFetching(filtersRef.current) ?? 0,
207
+ () => 0,
208
+ )
209
+ }
210
+
211
+ /**
212
+ * Number of mutations currently running in the scope's `QueryClient`. Mirrors
213
+ * {@link useIsFetching} for mutations.
214
+ *
215
+ * Pass `MutationFilters` to scope the count (e.g. `{ mutationKey: ['createUser'] }`).
216
+ */
217
+ export function useIsMutating(filters?: MutationFilters): number {
218
+ const qc = useUnit($queryClient)
219
+ const filtersRef = React.useRef(filters)
220
+ filtersRef.current = filters
221
+
222
+ return React.useSyncExternalStore(
223
+ React.useCallback(
224
+ (notify) => qc?.getMutationCache().subscribe(notify) ?? (() => {}),
225
+ [qc],
226
+ ),
227
+ () => qc?.isMutating(filtersRef.current) ?? 0,
228
+ () => 0,
229
+ )
230
+ }
231
+
169
232
  export interface UseInfiniteQueryResult<TData, TError> {
170
233
  data: TData | undefined
171
234
  error: TError | null
@@ -226,6 +289,119 @@ export function useInfiniteQuery<TData, TError = Error, TPageParam = unknown>(
226
289
  return { ...state, refresh, fetchNextPage, fetchPreviousPage }
227
290
  }
228
291
 
292
+ // =============================================================================
293
+ // useQueries — parallel reads across a static tuple of factories OR a family
294
+ // produced by `createQueries({ source, query })`.
295
+ // =============================================================================
296
+
297
+ type UseQueriesTuple = ReadonlyArray<QueryResult<any, any>>
298
+
299
+ /**
300
+ * Maps a tuple of `QueryResult<TData, TError>` to a tuple of
301
+ * `UseQueryResult<TData, TError>`, preserving per-element types so
302
+ * destructuring (`const [user, posts] = useQueries([...] as const)`)
303
+ * yields fully-typed entries.
304
+ */
305
+ export type UseQueriesTupleResult<T extends UseQueriesTuple> = {
306
+ [K in keyof T]: T[K] extends QueryResult<infer D, infer E>
307
+ ? UseQueryResult<D, E>
308
+ : never
309
+ }
310
+
311
+ export function useQueries<const T extends UseQueriesTuple>(
312
+ queries: T,
313
+ ): UseQueriesTupleResult<T>
314
+ export function useQueries<TItem, TData, TError>(
315
+ family: QueriesResult<TItem, TData, TError>,
316
+ ): ReadonlyArray<UseQueryResult<TData, TError>>
317
+ export function useQueries(
318
+ arg: UseQueriesTuple | QueriesResult<unknown, unknown, unknown>,
319
+ ): UseQueriesTupleResult<UseQueriesTuple> | ReadonlyArray<UseQueryResult<unknown, unknown>> {
320
+ if (Array.isArray(arg)) {
321
+ return useQueriesTuple(arg)
322
+ }
323
+ return useQueriesFamily(arg as QueriesResult<unknown, unknown, unknown>)
324
+ }
325
+
326
+ function useQueriesTuple<T extends UseQueriesTuple>(
327
+ queries: T,
328
+ ): UseQueriesTupleResult<T> {
329
+ // Twelve `useUnit` calls — count is FIXED, independent of N. The
330
+ // array argument may grow / shrink between renders; effector-react
331
+ // re-subscribes the underlying stores transparently.
332
+ //
333
+ // Rules-of-hooks constraint: the SHAPE of the call list must be
334
+ // stable, not the length of each input array. We satisfy this with a
335
+ // fixed sequence of 9 state-store calls + 3 event-bind calls.
336
+ const datas = useUnit(queries.map((q) => q.$data))
337
+ const errors = useUnit(queries.map((q) => q.$error))
338
+ const statuses = useUnit(queries.map((q) => q.$status))
339
+ const isPendings = useUnit(queries.map((q) => q.$isPending))
340
+ const isFetchings = useUnit(queries.map((q) => q.$isFetching))
341
+ const isSuccesses = useUnit(queries.map((q) => q.$isSuccess))
342
+ const isErrors = useUnit(queries.map((q) => q.$isError))
343
+ const isPlaceholderDatas = useUnit(queries.map((q) => q.$isPlaceholderData))
344
+ const fetchStatuses = useUnit(queries.map((q) => q.$fetchStatus))
345
+
346
+ const mounts = useUnit(queries.map((q) => q.mounted))
347
+ const unmounts = useUnit(queries.map((q) => q.unmounted))
348
+ const refreshes = useUnit(queries.map((q) => q.refresh))
349
+
350
+ React.useEffect(() => {
351
+ for (const m of mounts) m()
352
+ return () => {
353
+ for (const u of unmounts) u()
354
+ }
355
+ // The dep on length re-runs mount/unmount when the consumer swaps
356
+ // out the factory set (rare; most consumers pass a stable `as const`
357
+ // literal). Function refs from `useUnit` are stable per (unit, scope)
358
+ // — depending on their array identity would re-fire every render.
359
+ // eslint-disable-next-line react-hooks/exhaustive-deps
360
+ }, [queries.length])
361
+
362
+ return queries.map((_, i) => ({
363
+ data: datas[i],
364
+ error: errors[i],
365
+ status: statuses[i],
366
+ isPending: isPendings[i],
367
+ isFetching: isFetchings[i],
368
+ isSuccess: isSuccesses[i],
369
+ isError: isErrors[i],
370
+ isPlaceholderData: isPlaceholderDatas[i],
371
+ fetchStatus: fetchStatuses[i],
372
+ refresh: refreshes[i],
373
+ })) as UseQueriesTupleResult<T>
374
+ }
375
+
376
+ function useQueriesFamily<TItem, TData, TError>(
377
+ family: QueriesResult<TItem, TData, TError>,
378
+ ): ReadonlyArray<UseQueryResult<TData, TError>> {
379
+ const items = useUnit(family.$items)
380
+ const mount = useUnit(family.mounted)
381
+ const unmount = useUnit(family.unmounted)
382
+ const refreshOne = useUnit(family.refreshOne)
383
+
384
+ React.useEffect(() => {
385
+ mount()
386
+ return () => unmount()
387
+ }, [mount, unmount])
388
+
389
+ return items.map((it) => ({
390
+ data: it.data,
391
+ error: it.error,
392
+ status: it.status,
393
+ isPending: it.isPending,
394
+ isFetching: it.isFetching,
395
+ isSuccess: it.isSuccess,
396
+ isError: it.isError,
397
+ isPlaceholderData: it.isPlaceholderData,
398
+ fetchStatus: it.fetchStatus,
399
+ // Per-item refresh — routes through the family's `refreshOne(item)`
400
+ // so the consumer doesn't have to thread the source manually.
401
+ refresh: () => refreshOne(it.source),
402
+ }))
403
+ }
404
+
229
405
  // Suspense data path: read from a per-scope observer.
230
406
  //
231
407
  // The scope mount chain runs in useEffect, which is skipped while a component
@@ -575,3 +751,235 @@ function useSuspenseObserver<
575
751
  // observer to throw `fetchOptimistic` on.
576
752
  return observerInScope ?? transient
577
753
  }
754
+
755
+ // =============================================================================
756
+ // useSuspenseQueries — Suspense variant of `useQueries`. Same two overloads:
757
+ // static tuple of factories OR a family from `createQueries(...)`.
758
+ // =============================================================================
759
+
760
+ type UseSuspenseQueriesTuple = ReadonlyArray<QueryResult<any, any>>
761
+
762
+ export type UseSuspenseQueriesTupleResult<T extends UseSuspenseQueriesTuple> = {
763
+ [K in keyof T]: T[K] extends QueryResult<infer D, infer E>
764
+ ? UseSuspenseQueryResult<D, E>
765
+ : never
766
+ }
767
+
768
+ export function useSuspenseQueries<const T extends UseSuspenseQueriesTuple>(
769
+ queries: T,
770
+ ): UseSuspenseQueriesTupleResult<T>
771
+ export function useSuspenseQueries<TItem, TData, TError>(
772
+ family: QueriesResult<TItem, TData, TError>,
773
+ ): ReadonlyArray<UseSuspenseQueryResult<TData, TError>>
774
+ export function useSuspenseQueries(
775
+ arg: UseSuspenseQueriesTuple | QueriesResult<unknown, unknown, unknown>,
776
+ ):
777
+ | UseSuspenseQueriesTupleResult<UseSuspenseQueriesTuple>
778
+ | ReadonlyArray<UseSuspenseQueryResult<unknown, unknown>> {
779
+ if (Array.isArray(arg)) {
780
+ return useSuspenseQueriesTuple(arg)
781
+ }
782
+ return useSuspenseQueriesFamily(
783
+ arg as QueriesResult<unknown, unknown, unknown>,
784
+ )
785
+ }
786
+
787
+ function useSuspenseQueriesTuple<T extends UseSuspenseQueriesTuple>(
788
+ queries: T,
789
+ ): UseSuspenseQueriesTupleResult<T> {
790
+ // Mount lifecycle (same as `useQueriesTuple`).
791
+ const mounts = useUnit(queries.map((q) => q.mounted))
792
+ const unmounts = useUnit(queries.map((q) => q.unmounted))
793
+ const refreshes = useUnit(queries.map((q) => q.refresh))
794
+ React.useEffect(() => {
795
+ for (const m of mounts) m()
796
+ return () => {
797
+ for (const u of unmounts) u()
798
+ }
799
+ // eslint-disable-next-line react-hooks/exhaustive-deps
800
+ }, [queries.length])
801
+
802
+ // Per-query state from stores — fixed hook count.
803
+ const datas = useUnit(queries.map((q) => q.$data))
804
+ const errors = useUnit(queries.map((q) => q.$error))
805
+ const statuses = useUnit(queries.map((q) => q.$status))
806
+ const isFetchings = useUnit(queries.map((q) => q.$isFetching))
807
+ const isPlaceholderDatas = useUnit(queries.map((q) => q.$isPlaceholderData))
808
+ const fetchStatuses = useUnit(queries.map((q) => q.$fetchStatus))
809
+
810
+ // Observer / qc / key info per query — same fixed-count pattern.
811
+ const observersInScope = useUnit(queries.map((q) => q.$observer))
812
+ const qcs = useUnit(queries.map((q) => q.$queryClient))
813
+ const resolvedKeys = useUnit(
814
+ queries.map((q) => (q as unknown as SuspenseFactory<unknown>).__resolvedKey),
815
+ )
816
+ const enabledStates = useUnit(
817
+ queries.map((q) => (q as unknown as SuspenseFactory<unknown>).__enabled),
818
+ )
819
+
820
+ // Transient observers per slot (null when an in-scope observer
821
+ // exists or no qc is available). One useMemo across all queries —
822
+ // hook-count stable.
823
+ const transients = React.useMemo(() => {
824
+ return queries.map((q, i) => {
825
+ if (observersInScope[i]) return null
826
+ const qc = qcs[i]
827
+ if (!qc) return null
828
+ return (q as unknown as SuspenseFactory<any>).__createObserver(qc, {
829
+ queryKey: resolvedKeys[i],
830
+ enabled: enabledStates[i] as boolean,
831
+ })
832
+ })
833
+ // eslint-disable-next-line react-hooks/exhaustive-deps
834
+ }, [queries, ...observersInScope, ...qcs, ...resolvedKeys, ...enabledStates])
835
+
836
+ type SuspendableObserver = {
837
+ options: { queryKey: unknown }
838
+ subscribe(cb: () => void): () => void
839
+ fetchOptimistic(options: any): Promise<unknown>
840
+ getOptimisticResult(options: any): {
841
+ status: 'pending' | 'success' | 'error'
842
+ data: unknown
843
+ error: unknown
844
+ isFetching: boolean
845
+ isPlaceholderData: boolean
846
+ fetchStatus: FetchStatus
847
+ }
848
+ }
849
+ const observers = queries.map(
850
+ (_, i) =>
851
+ ((observersInScope[i] ?? transients[i]) ?? null) as
852
+ | SuspendableObserver
853
+ | null,
854
+ )
855
+
856
+ // Subscribe to every live observer in one effect so the consumer
857
+ // re-renders when ANY of them notifies.
858
+ const [, forceRender] = React.useReducer((x: number) => x + 1, 0)
859
+ React.useEffect(() => {
860
+ const unsubs = observers.map((obs) =>
861
+ obs ? obs.subscribe(forceRender) : null,
862
+ )
863
+ return () => {
864
+ for (const u of unsubs) u?.()
865
+ }
866
+ // eslint-disable-next-line react-hooks/exhaustive-deps
867
+ }, [queries.length, ...observers])
868
+
869
+ // Per-slot live result — from observer if available (synchronous
870
+ // QueryCache read), otherwise null and we fall back to the
871
+ // effector-store snapshot. Mirrors the dual path in
872
+ // `useSuspenseQuery` so SSR scopes (`$observer` + `$queryClient`
873
+ // both null) keep working.
874
+ const liveResults = observers.map((obs) =>
875
+ obs ? obs.getOptimisticResult(obs.options) : null,
876
+ )
877
+
878
+ // Errors first — first error wins.
879
+ for (let i = 0; i < queries.length; i++) {
880
+ const live = liveResults[i]
881
+ if (live) {
882
+ if (live.status === 'error') throw live.error
883
+ } else if (statuses[i] === 'error') {
884
+ throw errors[i]
885
+ }
886
+ }
887
+
888
+ // Then pending — collect every pending query's inflight promise into
889
+ // a single `Promise.all` thrown to Suspense. The store-only path has
890
+ // no observer to fetch with: that's a misconfiguration (no QC, no
891
+ // prefetch), throw a clear error.
892
+ const pendingPromises: Array<Promise<unknown>> = []
893
+ for (let i = 0; i < queries.length; i++) {
894
+ const live = liveResults[i]
895
+ const obs = observers[i]
896
+ if (live) {
897
+ if (live.status === 'pending' && obs) {
898
+ pendingPromises.push(obs.fetchOptimistic(obs.options))
899
+ }
900
+ } else if (statuses[i] === 'pending') {
901
+ throw new Error(
902
+ '[@effector-tanstack-query/react] useSuspenseQueries: no QueryClient is set. ' +
903
+ 'Call setQueryClient(qc) or pass it to fork({ values: [[$queryClient, qc]] }).',
904
+ )
905
+ }
906
+ }
907
+ if (pendingPromises.length > 0) throw Promise.all(pendingPromises)
908
+
909
+ // All success — shape the result tuple. Prefer the observer's live
910
+ // result when present (reflects in-flight refetches), fall back to
911
+ // the store snapshot otherwise.
912
+ return queries.map((_, i) => {
913
+ const live = liveResults[i]
914
+ return {
915
+ data: (live ? live.data : datas[i]) as unknown,
916
+ error: (live ? live.error : (errors[i] ?? null)) as unknown,
917
+ status: 'success' as const,
918
+ isPending: false as const,
919
+ isSuccess: true as const,
920
+ isError: false as const,
921
+ isFetching: (live ? live.isFetching : isFetchings[i]) as boolean,
922
+ isPlaceholderData: (live
923
+ ? live.isPlaceholderData
924
+ : isPlaceholderDatas[i]) as boolean,
925
+ fetchStatus: (live ? live.fetchStatus : fetchStatuses[i]) as FetchStatus,
926
+ refresh: refreshes[i] as () => void,
927
+ }
928
+ }) as UseSuspenseQueriesTupleResult<T>
929
+ }
930
+
931
+ interface FamilyInternals<TItem> {
932
+ __queryFor: (item: TItem) => {
933
+ queryKey: ReadonlyArray<unknown>
934
+ queryFn?: unknown
935
+ }
936
+ }
937
+
938
+ function useSuspenseQueriesFamily<TItem, TData, TError>(
939
+ family: QueriesResult<TItem, TData, TError>,
940
+ ): ReadonlyArray<UseSuspenseQueryResult<TData, TError>> {
941
+ const mount = useUnit(family.mounted)
942
+ const unmount = useUnit(family.unmounted)
943
+ const refreshOne = useUnit(family.refreshOne)
944
+ React.useEffect(() => {
945
+ mount()
946
+ return () => unmount()
947
+ }, [mount, unmount])
948
+
949
+ const items = useUnit(family.$items)
950
+ const qc = useUnit(family.$queryClient)
951
+
952
+ // Error wins over pending.
953
+ for (const it of items) {
954
+ if (it.status === 'error') throw it.error
955
+ }
956
+
957
+ const pending = items.filter((it) => it.status === 'pending')
958
+ if (pending.length > 0) {
959
+ if (!qc) {
960
+ throw new Error(
961
+ '[@effector-tanstack-query/react] useSuspenseQueries: no QueryClient is set. ' +
962
+ 'Call setQueryClient(qc) or pass it to fork({ values: [[$queryClient, qc]] }).',
963
+ )
964
+ }
965
+ const queryFor = (family as unknown as FamilyInternals<TItem>).__queryFor
966
+ throw Promise.all(
967
+ pending.map((it) =>
968
+ qc.fetchQuery(queryFor(it.source) as any).catch(() => undefined),
969
+ ),
970
+ )
971
+ }
972
+
973
+ return items.map((it) => ({
974
+ data: it.data as TData,
975
+ error: it.error,
976
+ status: 'success' as const,
977
+ isPending: false as const,
978
+ isSuccess: true as const,
979
+ isError: false as const,
980
+ isFetching: it.isFetching,
981
+ isPlaceholderData: it.isPlaceholderData,
982
+ fetchStatus: it.fetchStatus,
983
+ refresh: () => refreshOne(it.source),
984
+ }))
985
+ }