From bee77135d9879250f4221a3c23696ead6753852b Mon Sep 17 00:00:00 2001 From: Sergey Volkov Date: Sat, 23 Aug 2025 14:51:26 +0300 Subject: [PATCH 1/2] feat: added lazy option for query and mutation; refactor: useless reactions --- .changeset/blue-pans-make.md | 5 + .changeset/every-parks-march.md | 5 + src/inifinite-query.ts | 210 ++++++++++++++++++------------ src/inifinite-query.types.ts | 14 +- src/mutation.ts | 222 +++++++++++++++++--------------- src/mutation.types.ts | 31 +++++ src/query.test.ts | 168 +++++++++++++++++++++++- src/query.ts | 215 ++++++++++++++++++------------- src/query.types.ts | 21 ++- 9 files changed, 605 insertions(+), 286 deletions(-) create mode 100644 .changeset/blue-pans-make.md create mode 100644 .changeset/every-parks-march.md diff --git a/.changeset/blue-pans-make.md b/.changeset/blue-pans-make.md new file mode 100644 index 0000000..aab6dcc --- /dev/null +++ b/.changeset/blue-pans-make.md @@ -0,0 +1,5 @@ +--- +"mobx-tanstack-query": patch +--- + +remove a lot of useless reactions (replaced it by more simple callbacks) diff --git a/.changeset/every-parks-march.md b/.changeset/every-parks-march.md new file mode 100644 index 0000000..b6e3766 --- /dev/null +++ b/.changeset/every-parks-march.md @@ -0,0 +1,5 @@ +--- +"mobx-tanstack-query": minor +--- + +added `lazy` option for queries and mutations which work on lazy observables from mobx diff --git a/src/inifinite-query.ts b/src/inifinite-query.ts index df18c23..31bb6ed 100644 --- a/src/inifinite-query.ts +++ b/src/inifinite-query.ts @@ -19,10 +19,14 @@ import { makeObservable, observable, runInAction, + onBecomeUnobserved, + onBecomeObserved, } from 'mobx'; import { InfiniteQueryConfig, + InfiniteQueryDoneListener, + InfiniteQueryErrorListener, InfiniteQueryFlattenConfig, InfiniteQueryInvalidateParams, InfiniteQueryOptions, @@ -32,6 +36,8 @@ import { } from './inifinite-query.types'; import { AnyQueryClient, QueryClientHooks } from './query-client.types'; +const enableHolder = () => false; + export class InfiniteQuery< TQueryFnData = unknown, TError = DefaultError, @@ -40,7 +46,7 @@ export class InfiniteQuery< TQueryKey extends QueryKey = QueryKey, > implements Disposable { - protected abortController: AbortController; + protected abortController: LinkedAbortController; protected queryClient: AnyQueryClient; protected _result: InfiniteQueryObserverResult; @@ -68,9 +74,9 @@ export class InfiniteQuery< TPageParam >; - isResultRequsted: boolean; - private isEnabledOnResultDemand: boolean; + private isResultRequsted: boolean; + private isLazy?: boolean; /** * This parameter is responsible for holding the enabled value, @@ -85,6 +91,8 @@ export class InfiniteQuery< >['enabled']; private _observerSubscription?: VoidFunction; private hooks?: QueryClientHooks; + private errorListeners: InfiniteQueryErrorListener[]; + private doneListeners: InfiniteQueryDoneListener[]; constructor( config: InfiniteQueryConfig< @@ -147,12 +155,20 @@ export class InfiniteQuery< this._result = undefined as any; this.isResultRequsted = false; this.isEnabledOnResultDemand = config.enableOnDemand ?? false; + this.errorListeners = []; + this.doneListeners = []; this.hooks = 'hooks' in this.queryClient ? this.queryClient.hooks : undefined; + this.isLazy = this.config.lazy; - if ('queryFeatures' in queryClient && config.enableOnDemand == null) { - this.isEnabledOnResultDemand = - queryClient.queryFeatures.enableOnDemand ?? false; + if ('queryFeatures' in queryClient) { + if (this.config.lazy === undefined) { + this.isLazy = queryClient.queryFeatures.lazy ?? false; + } + if (config.enableOnDemand === undefined) { + this.isEnabledOnResultDemand = + queryClient.queryFeatures.enableOnDemand ?? false; + } } observable.deep(this, '_result'); @@ -164,10 +180,11 @@ export class InfiniteQuery< makeObservable(this); - this.options = this.queryClient.defaultQueryOptions({ - ...restOptions, - ...getDynamicOptions?.(this), - } as any) as InfiniteQueryOptions< + const isQueryKeyDynamic = typeof queryKeyOrDynamicQueryKey === 'function'; + + this.options = this.queryClient.defaultQueryOptions( + restOptions as any, + ) as InfiniteQueryOptions< TQueryFnData, TError, TPageParam, @@ -177,24 +194,24 @@ export class InfiniteQuery< this.options.structuralSharing = this.options.structuralSharing ?? false; - this.processOptions(this.options); + const getAllDynamicOptions = + getDynamicOptions || isQueryKeyDynamic + ? () => { + const freshDynamicOptions = { + ...getDynamicOptions?.(this), + }; - if (typeof queryKeyOrDynamicQueryKey === 'function') { - this.options.queryKey = queryKeyOrDynamicQueryKey(); - - reaction( - () => queryKeyOrDynamicQueryKey(), - (queryKey) => { - this.update({ - queryKey, - }); - }, - { - signal: this.abortController.signal, - delay: this.config.dynamicOptionsUpdateDelay, - }, - ); - } else { + if (isQueryKeyDynamic) { + freshDynamicOptions.queryKey = queryKeyOrDynamicQueryKey(); + } + + return freshDynamicOptions; + } + : undefined; + + if (getAllDynamicOptions) { + Object.assign(this.options, getAllDynamicOptions()); + } else if (!isQueryKeyDynamic) { this.options.queryKey = queryKeyOrDynamicQueryKey ?? this.options.queryKey ?? []; } @@ -205,47 +222,81 @@ export class InfiniteQuery< queryClient.getDefaultOptions().queries?.notifyOnChangeProps ?? 'all'; + this.processOptions(this.options); + // @ts-expect-error this.queryObserver = new InfiniteQueryObserver(queryClient, this.options); // @ts-expect-error this.updateResult(this.queryObserver.getOptimisticResult(this.options)); - this._observerSubscription = this.queryObserver.subscribe( - this.updateResult, - ); + if (this.isLazy) { + let dynamicOptionsDisposeFn: VoidFunction | undefined; - if (getDynamicOptions) { - reaction(() => getDynamicOptions(this), this.update, { - signal: this.abortController.signal, - delay: this.config.dynamicOptionsUpdateDelay, + onBecomeObserved(this, '_result', () => { + if (!this._observerSubscription) { + if (getAllDynamicOptions) { + this.update(getAllDynamicOptions()); + } + this._observerSubscription = this.queryObserver.subscribe( + this.updateResult, + ); + if (getAllDynamicOptions) { + dynamicOptionsDisposeFn = reaction( + getAllDynamicOptions, + this.update, + { + delay: this.config.dynamicOptionsUpdateDelay, + signal: config.abortSignal, + fireImmediately: true, + }, + ); + } + } }); - } - if (this.isEnabledOnResultDemand) { - reaction( - () => this.isResultRequsted, - (isRequested) => { - if (isRequested) { - this.update(getDynamicOptions?.(this) ?? {}); - } - }, - { + const cleanup = () => { + if (this._observerSubscription) { + dynamicOptionsDisposeFn?.(); + this._observerSubscription(); + this._observerSubscription = undefined; + dynamicOptionsDisposeFn = undefined; + config.abortSignal?.removeEventListener('abort', cleanup); + } + }; + + onBecomeUnobserved(this, '_result', cleanup); + config.abortSignal?.addEventListener('abort', cleanup); + } else { + if (isQueryKeyDynamic) { + reaction( + queryKeyOrDynamicQueryKey, + (queryKey) => this.update({ queryKey }), + { + signal: this.abortController.signal, + delay: this.config.dynamicOptionsUpdateDelay, + }, + ); + } + if (getDynamicOptions) { + reaction(() => getDynamicOptions(this), this.update, { signal: this.abortController.signal, - fireImmediately: true, - }, + delay: this.config.dynamicOptionsUpdateDelay, + }); + } + this._observerSubscription = this.queryObserver.subscribe( + this.updateResult, ); + this.abortController.signal.addEventListener('abort', this.handleAbort); } if (config.onDone) { - this.onDone(config.onDone); + this.doneListeners.push(config.onDone); } if (config.onError) { - this.onError(config.onError); + this.errorListeners.push(config.onError); } - this.abortController.signal.addEventListener('abort', this.handleAbort); - this.config.onInit?.(this); this.hooks?.onInfiniteQueryInit?.(this); } @@ -316,12 +367,14 @@ export class InfiniteQuery< // @ts-expect-error this.queryObserver.setOptions(this.options); + + if (this.isLazy) { + this.updateResult(this.queryObserver.getCurrentResult()); + } } private isEnableHolded = false; - private enableHolder = () => false; - private processOptions = ( options: InfiniteQueryOptions< TQueryFnData, @@ -338,14 +391,14 @@ export class InfiniteQuery< // to do this, we hold the original value of the enabled option // and set enabled to false until the user requests the result (this.isResultRequsted) if (this.isEnabledOnResultDemand) { - if (this.isEnableHolded && options.enabled !== this.enableHolder) { + if (this.isEnableHolded && options.enabled !== enableHolder) { this.holdedEnabledOption = options.enabled; } if (this.isResultRequsted) { if (this.isEnableHolded) { options.enabled = - this.holdedEnabledOption === this.enableHolder + this.holdedEnabledOption === enableHolder ? undefined : this.holdedEnabledOption; this.isEnableHolded = false; @@ -353,16 +406,17 @@ export class InfiniteQuery< } else { this.isEnableHolded = true; this.holdedEnabledOption = options.enabled; - options.enabled = this.enableHolder; + options.enabled = enableHolder; } } }; public get result() { - if (!this.isResultRequsted) { + if (this.isEnabledOnResultDemand && !this.isResultRequsted) { runInAction(() => { this.isResultRequsted = true; }); + this.update({}); } return this._result; } @@ -370,8 +424,14 @@ export class InfiniteQuery< /** * Modify this result so it matches the tanstack query result. */ - private updateResult(nextResult: InfiniteQueryObserverResult) { - this._result = nextResult || {}; + private updateResult(result: InfiniteQueryObserverResult) { + this._result = result || {}; + + if (result.isSuccess && !result.error && result.fetchStatus === 'idle') { + this.doneListeners.forEach((fn) => fn(result.data!, void 0)); + } else if (result.error) { + this.errorListeners.forEach((fn) => fn(result.error!, void 0)); + } } async refetch(options?: RefetchOptions) { @@ -408,35 +468,12 @@ export class InfiniteQuery< } as any); } - onDone(onDoneCallback: (data: TData, payload: void) => void): void { - reaction( - () => { - const { error, isSuccess, fetchStatus } = this._result; - return isSuccess && !error && fetchStatus === 'idle'; - }, - (isDone) => { - if (isDone) { - onDoneCallback(this._result.data!, void 0); - } - }, - { - signal: this.abortController.signal, - }, - ); + onDone(doneListener: InfiniteQueryDoneListener): void { + this.doneListeners.push(doneListener); } - onError(onErrorCallback: (error: TError, payload: void) => void): void { - reaction( - () => this._result.error, - (error) => { - if (error) { - onErrorCallback(error, void 0); - } - }, - { - signal: this.abortController.signal, - }, - ); + onError(errorListener: InfiniteQueryErrorListener): void { + this.errorListeners.push(errorListener); } async start({ @@ -457,6 +494,9 @@ export class InfiniteQuery< protected handleAbort = () => { this._observerSubscription?.(); + this.doneListeners = []; + this.errorListeners = []; + this.queryObserver.destroy(); this.isResultRequsted = false; diff --git a/src/inifinite-query.types.ts b/src/inifinite-query.types.ts index 010028c..d12712c 100644 --- a/src/inifinite-query.types.ts +++ b/src/inifinite-query.types.ts @@ -16,6 +16,16 @@ import { QueryResetParams, } from './query.types'; +export type InfiniteQueryErrorListener = ( + error: TError, + payload: void, +) => void; + +export type InfiniteQueryDoneListener = ( + data: TData, + payload: void, +) => void; + export interface InfiniteQueryInvalidateParams extends QueryInvalidateParams {} /** @@ -268,8 +278,8 @@ export interface InfiniteQueryConfig< query: InfiniteQuery, ) => void; abortSignal?: AbortSignal; - onDone?: (data: TData, payload: void) => void; - onError?: (error: TError, payload: void) => void; + onDone?: InfiniteQueryDoneListener; + onError?: InfiniteQueryErrorListener; /** * Dynamic query parameters, when result of this function changed query will be updated * (reaction -> setOptions) diff --git a/src/mutation.ts b/src/mutation.ts index 80feda9..becb466 100644 --- a/src/mutation.ts +++ b/src/mutation.ts @@ -6,11 +6,21 @@ import { MutationOptions, } from '@tanstack/query-core'; import { LinkedAbortController } from 'linked-abort-controller'; -import { action, makeObservable, observable, reaction } from 'mobx'; +import { + action, + makeObservable, + observable, + onBecomeObserved, + onBecomeUnobserved, +} from 'mobx'; import { MutationConfig, + MutationDoneListener, + MutationErrorListener, + MutationFeatures, MutationInvalidateQueriesOptions, + MutationSettledListener, } from './mutation.types'; import { AnyQueryClient, QueryClientHooks } from './query-client.types'; @@ -21,7 +31,7 @@ export class Mutation< TContext = unknown, > implements Disposable { - protected abortController: AbortController; + protected abortController: LinkedAbortController; protected queryClient: AnyQueryClient; mutationOptions: MutationObserverOptions; @@ -29,6 +39,18 @@ export class Mutation< result: MutationObserverResult; + private isLazy?: boolean; + private isResetOnDestroy?: MutationFeatures['resetOnDestroy']; + + private settledListeners: MutationSettledListener< + TData, + TError, + TVariables, + TContext + >[]; + private errorListeners: MutationErrorListener[]; + private doneListeners: MutationDoneListener[]; + private _observerSubscription?: VoidFunction; private hooks?: QueryClientHooks; @@ -45,21 +67,38 @@ export class Mutation< this.abortController = new LinkedAbortController(config.abortSignal); this.queryClient = queryClient; this.result = undefined as any; + this.isLazy = this.config.lazy; + this.settledListeners = []; + this.errorListeners = []; + this.doneListeners = []; + this.isResetOnDestroy = + this.config.resetOnDestroy ?? this.config.resetOnDispose; observable.deep(this, 'result'); action.bound(this, 'updateResult'); makeObservable(this); - const invalidateByKey = - providedInvalidateByKey ?? - ('mutationFeatures' in queryClient - ? queryClient.mutationFeatures.invalidateByKey - : null); + let invalidateByKey: MutationFeatures['invalidateByKey'] = + providedInvalidateByKey; + + if ('mutationFeatures' in queryClient) { + if (providedInvalidateByKey === undefined) { + invalidateByKey = queryClient.mutationFeatures.invalidateByKey; + } + if (this.config.lazy === undefined) { + this.isLazy = queryClient.mutationFeatures.lazy; + } + if (this.isResetOnDestroy === undefined) { + this.isResetOnDestroy = + queryClient.mutationFeatures.resetOnDestroy ?? + queryClient.mutationFeatures.resetOnDispose; + } + + this.hooks = queryClient.hooks; + } this.mutationOptions = this.queryClient.defaultMutationOptions(restOptions); - this.hooks = - 'hooks' in this.queryClient ? this.queryClient.hooks : undefined; this.mutationObserver = new MutationObserver< TData, @@ -76,21 +115,28 @@ export class Mutation< this.updateResult(this.mutationObserver.getCurrentResult()); - this._observerSubscription = this.mutationObserver.subscribe( - this.updateResult, - ); - - this.abortController.signal.addEventListener('abort', () => { - this._observerSubscription?.(); + if (this.isLazy) { + onBecomeObserved(this, 'result', () => { + if (!this._observerSubscription) { + this.updateResult(this.mutationObserver.getCurrentResult()); + this._observerSubscription = this.mutationObserver.subscribe( + this.updateResult, + ); + } + }); + onBecomeUnobserved(this, 'result', () => { + if (this._observerSubscription) { + this._observerSubscription?.(); + this._observerSubscription = undefined; + } + }); + } else { + this._observerSubscription = this.mutationObserver.subscribe( + this.updateResult, + ); - if ( - config.resetOnDispose || - ('mutationFeatures' in queryClient && - queryClient.mutationFeatures.resetOnDispose) - ) { - this.reset(); - } - }); + this.abortController.signal.addEventListener('abort', this.handleAbort); + } if (invalidateQueries) { this.onDone((data, payload) => { @@ -143,7 +189,25 @@ export class Mutation< variables: TVariables, options?: MutationOptions, ) { - await this.mutationObserver.mutate(variables, options); + if (this.isLazy) { + let error: any; + + try { + await this.mutationObserver.mutate(variables, options); + } catch (error_) { + error = error_; + } + + const result = this.mutationObserver.getCurrentResult(); + this.updateResult(result); + + if (error && this.mutationOptions.throwOnError) { + throw error; + } + } else { + await this.mutationObserver.mutate(variables, options); + } + return this.result; } @@ -151,93 +215,46 @@ export class Mutation< variables: TVariables, options?: MutationOptions, ) { - await this.mutationObserver.mutate(variables, options); - return this.result; + return await this.mutate(variables, options); } /** * Modify this result so it matches the tanstack query result. */ private updateResult( - nextResult: MutationObserverResult, + result: MutationObserverResult, ) { - this.result = nextResult || {}; + this.result = result || {}; + + if (result.isSuccess && !result.error) { + this.doneListeners.forEach((fn) => + fn(result.data!, result.variables!, result.context), + ); + } else if (result.error) { + this.errorListeners.forEach((fn) => + fn(result.error!, result.variables!, result.context), + ); + } + + if (!result.isPending && (result.isError || result.isSuccess)) { + this.settledListeners.forEach((fn) => + fn(result.data!, result.error, result.variables!, result.context), + ); + } } onSettled( - onSettledCallback: ( - data: TData | undefined, - error: TError | null, - variables: TVariables, - context: TContext | undefined, - ) => void, + listener: MutationSettledListener, ): void { - reaction( - () => { - const { isSuccess, isError, isPending } = this.result; - return !isPending && (isSuccess || isError); - }, - (isSettled) => { - if (isSettled) { - onSettledCallback( - this.result.data, - this.result.error, - this.result.variables!, - this.result.context, - ); - } - }, - { - signal: this.abortController.signal, - }, - ); + this.settledListeners.push(listener); } - onDone( - onDoneCallback: ( - data: TData, - payload: TVariables, - context: TContext | undefined, - ) => void, - ): void { - reaction( - () => { - const { error, isSuccess } = this.result; - return isSuccess && !error; - }, - (isDone) => { - if (isDone) { - onDoneCallback( - this.result.data!, - this.result.variables!, - this.result.context, - ); - } - }, - { - signal: this.abortController.signal, - }, - ); + onDone(listener: MutationDoneListener): void { + this.doneListeners.push(listener); } - onError( - onErrorCallback: ( - error: TError, - payload: TVariables, - context: TContext | undefined, - ) => void, - ): void { - reaction( - () => this.result.error, - (error) => { - if (error) { - onErrorCallback(error, this.result.variables!, this.result.context); - } - }, - { - signal: this.abortController.signal, - }, - ); + onError(listener: MutationErrorListener): void { + this.errorListeners.push(listener); } reset() { @@ -247,16 +264,11 @@ export class Mutation< protected handleAbort = () => { this._observerSubscription?.(); - let isNeedToReset = - this.config.resetOnDestroy || this.config.resetOnDispose; - - if ('mutationFeatures' in this.queryClient && !isNeedToReset) { - isNeedToReset = - this.queryClient.mutationFeatures.resetOnDestroy || - this.queryClient.mutationFeatures.resetOnDispose; - } + this.doneListeners = []; + this.errorListeners = []; + this.settledListeners = []; - if (isNeedToReset) { + if (this.isResetOnDestroy) { this.reset(); } diff --git a/src/mutation.types.ts b/src/mutation.types.ts index 35dbb5b..8c2daf6 100644 --- a/src/mutation.types.ts +++ b/src/mutation.types.ts @@ -28,6 +28,13 @@ export interface MutationFeatures { * Reset mutation when destroy or abort signal is called */ resetOnDestroy?: boolean; + /** + * **EXPERIMENTAL** + * + * Make all mutation reactions and subscriptions lazy. + * They exists only when mutation result is observed. + */ + lazy?: boolean; } /** @@ -61,6 +68,30 @@ export type MobxMutationFunction< TVariables = unknown, > = MutationFn; +export type MutationSettledListener< + TData = unknown, + TError = DefaultError, + TVariables = void, + TContext = unknown, +> = ( + data: TData | undefined, + error: TError | null, + variables: TVariables, + context: TContext | undefined, +) => void; + +export type MutationErrorListener< + TError = DefaultError, + TVariables = void, + TContext = unknown, +> = (error: TError, payload: TVariables, context: TContext | undefined) => void; + +export type MutationDoneListener< + TData = unknown, + TVariables = void, + TContext = unknown, +> = (data: TData, payload: TVariables, context: TContext | undefined) => void; + export interface MutationConfig< TData = unknown, TVariables = void, diff --git a/src/query.test.ts b/src/query.test.ts index 7e08f4f..bd71397 100644 --- a/src/query.test.ts +++ b/src/query.test.ts @@ -27,7 +27,7 @@ import { test, vi, } from 'vitest'; -import { waitAsync } from 'yummies/async'; +import { sleep, waitAsync } from 'yummies/async'; import { createQuery } from './preset'; import { Query } from './query'; @@ -291,6 +291,28 @@ describe('Query', () => { query.dispose(); }); + it('should be DISABLED from default query options (from query client) (lazy:true)', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + enabled: false, + }, + }, + }); + const query = new QueryMock( + { + queryKey: ['test', 0 as number] as const, + queryFn: () => 100, + lazy: true, + }, + queryClient, + ); + + expect(query.spies.queryFn).toBeCalledTimes(0); + + query.dispose(); + }); + it('should be reactive after change queryKey', async () => { const query = new QueryMock({ queryKey: ['test', 0 as number] as const, @@ -308,6 +330,24 @@ describe('Query', () => { query.dispose(); }); + it('should be reactive after change queryKey (lazy:true)', async () => { + const query = new QueryMock({ + queryKey: ['test', 0 as number] as const, + enabled: ({ queryKey }) => queryKey[1] > 0, + queryFn: () => 100, + lazy: true, + }); + + query.update({ queryKey: ['test', 1] as const }); + + await when(() => !query._rawResult.isLoading); + + expect(query.spies.queryFn).toBeCalledTimes(1); + expect(query.spies.queryFn).nthReturnedWith(1, 100); + + query.dispose(); + }); + it('should be reactive dependent on another query (runs before declartion)', async () => { const disabledQuery = new QueryMock({ queryKey: ['test', 0 as number] as const, @@ -339,6 +379,39 @@ describe('Query', () => { dependentQuery.dispose(); }); + it('should be reactive dependent on another query (runs before declartion) (lazy: true)', async () => { + const disabledQuery = new QueryMock({ + queryKey: ['test', 0 as number] as const, + enabled: ({ queryKey }) => queryKey[1] > 0, + queryFn: () => 100, + lazy: true, + }); + + disabledQuery.update({ queryKey: ['test', 1] as const }); + + const dependentQuery = new QueryMock({ + options: () => ({ + enabled: !!disabledQuery.options.enabled, + queryKey: [...disabledQuery.options.queryKey, 'dependent'], + }), + queryFn: ({ queryKey }) => queryKey, + lazy: true, + }); + + await when(() => !disabledQuery._rawResult.isLoading); + await when(() => !dependentQuery._rawResult.isLoading); + + expect(dependentQuery.spies.queryFn).toBeCalledTimes(1); + expect(dependentQuery.spies.queryFn).nthReturnedWith(1, [ + 'test', + 1, + 'dependent', + ]); + + disabledQuery.dispose(); + dependentQuery.dispose(); + }); + it('should be reactive dependent on another query (runs after declaration)', async () => { const tempDisabledQuery = new QueryMock({ queryKey: ['test', 0 as number] as const, @@ -373,6 +446,87 @@ describe('Query', () => { }); }); + it('should be reactive dependent on another query (runs after declaration) (updating lazy query)', async () => { + const tempDisabledQuery = new QueryMock({ + queryKey: ['test', 0 as number] as const, + enabled: ({ queryKey }) => queryKey[1] > 0, + queryFn: () => 100, + lazy: true, + }); + + const dependentQuery = new QueryMock({ + options: () => ({ + enabled: !!tempDisabledQuery.options.enabled, + queryKey: [...tempDisabledQuery.options.queryKey, 'dependent'], + }), + queryFn: ({ queryKey }) => queryKey, + }); + + tempDisabledQuery.update({ queryKey: ['test', 1] as const }); + + await when(() => !tempDisabledQuery._rawResult.isLoading); + await when(() => !dependentQuery._rawResult.isLoading); + + expect(dependentQuery.spies.queryFn).toBeCalledTimes(1); + // результат с 0 потому что options.enabled у первой квери - это функция и + // !!tempDisabledQuery.options.enabled будет всегда true + expect(dependentQuery.spies.queryFn).nthReturnedWith(1, [ + 'test', + 0, + 'dependent', + ]); + + tempDisabledQuery.dispose(); + dependentQuery.dispose(); + }); + + it('should NOT be reactive dependent on another query because lazy queries has not subscriptions', async () => { + const tempDisabledQuery = new QueryMock({ + queryKey: ['test', 0 as number] as const, + enabled: ({ queryKey }) => queryKey[1] > 0, + queryFn: () => 100, + lazy: true, + }); + + const dependentQuery = new QueryMock({ + options: () => { + return { + enabled: !!tempDisabledQuery.options.enabled, + queryKey: [...tempDisabledQuery.options.queryKey, 'dependent'], + }; + }, + queryFn: ({ queryKey }) => { + return queryKey; + }, + lazy: true, + }); + + tempDisabledQuery.update({ queryKey: ['test', 1] as const }); + + await sleep(100); + + expect(dependentQuery.spies.queryFn).toBeCalledTimes(0); + + await sleep(100); + + // НО когда мы начнем следить за кверей то все заработает + reaction( + () => dependentQuery.result.data, + () => {}, + { fireImmediately: true }, + ); + + expect(dependentQuery.spies.queryFn).toBeCalledTimes(1); + expect(dependentQuery.spies.queryFn).nthReturnedWith(1, [ + 'test', + 1, + 'dependent', + ]); + + tempDisabledQuery.dispose(); + dependentQuery.dispose(); + }); + describe('"options" reactive parameter', () => { it('"options.queryKey" should updates query', async () => { const boxCounter = observable.box(0); @@ -835,8 +989,6 @@ describe('Query', () => { }, } as Record; - console.info('asdfdsaf', task.name); - const query = new QueryMock( { queryKey: [task.name, '2'], @@ -1420,6 +1572,7 @@ describe('Query', () => { abortController1.abort(); expect(query1.result).toStrictEqual({ + ...query1.result, data: undefined, dataUpdatedAt: 0, error: null, @@ -1447,6 +1600,7 @@ describe('Query', () => { status: 'pending', }); expect(query2.result).toStrictEqual({ + ...query2.result, data: undefined, dataUpdatedAt: 0, error: null, @@ -1475,6 +1629,7 @@ describe('Query', () => { }); await waitAsync(10); expect(query1.result).toStrictEqual({ + ...query1.result, data: undefined, dataUpdatedAt: 0, error: null, @@ -1502,6 +1657,7 @@ describe('Query', () => { status: 'pending', }); expect(query2.result).toStrictEqual({ + ...query2.result, data: 'foo', dataUpdatedAt: query2.result.dataUpdatedAt, error: null, @@ -1530,6 +1686,7 @@ describe('Query', () => { }); await waitAsync(10); expect(query1.result).toStrictEqual({ + ...query1.result, data: undefined, dataUpdatedAt: 0, error: null, @@ -1582,6 +1739,7 @@ describe('Query', () => { abortController1.abort(); expect(query1.result).toStrictEqual({ + ...query1.result, data: undefined, dataUpdatedAt: 0, error: null, @@ -1609,6 +1767,7 @@ describe('Query', () => { status: 'pending', }); expect(query2.result).toStrictEqual({ + ...query2.result, data: undefined, dataUpdatedAt: 0, error: null, @@ -1637,6 +1796,7 @@ describe('Query', () => { }); await waitAsync(10); expect(query1.result).toStrictEqual({ + ...query1.result, data: undefined, dataUpdatedAt: 0, error: null, @@ -1664,6 +1824,7 @@ describe('Query', () => { status: 'pending', }); expect(query2.result).toStrictEqual({ + ...query2.result, data: 'foo', dataUpdatedAt: query2.result.dataUpdatedAt, error: null, @@ -1692,6 +1853,7 @@ describe('Query', () => { }); await waitAsync(10); expect(query1.result).toStrictEqual({ + ...query1.result, data: undefined, dataUpdatedAt: 0, error: null, diff --git a/src/query.ts b/src/query.ts index d8dd686..7dc21ee 100644 --- a/src/query.ts +++ b/src/query.ts @@ -13,6 +13,8 @@ import { action, makeObservable, observable, + onBecomeObserved, + onBecomeUnobserved, reaction, runInAction, } from 'mobx'; @@ -22,6 +24,8 @@ import { AnyQueryClient, QueryClientHooks } from './query-client.types'; import { QueryOptionsParams } from './query-options'; import { QueryConfig, + QueryDoneListener, + QueryErrorListener, QueryInvalidateParams, QueryOptions, QueryResetParams, @@ -29,6 +33,8 @@ import { QueryUpdateOptionsAllVariants, } from './query.types'; +const enableHolder = () => false; + export class Query< TQueryFnData = unknown, TError = DefaultError, @@ -37,7 +43,7 @@ export class Query< TQueryKey extends QueryKey = QueryKey, > implements Disposable { - protected abortController: AbortController; + protected abortController: LinkedAbortController; protected queryClient: AnyQueryClient; protected _result: QueryObserverResult; @@ -51,9 +57,9 @@ export class Query< TQueryKey >; - isResultRequsted: boolean; - private isEnabledOnResultDemand: boolean; + isResultRequsted: boolean; + private isLazy?: boolean; /** * This parameter is responsible for holding the enabled value, @@ -68,6 +74,8 @@ export class Query< >['enabled']; private _observerSubscription?: VoidFunction; private hooks?: QueryClientHooks; + private errorListeners: QueryErrorListener[]; + private doneListeners: QueryDoneListener[]; protected config: QueryConfig< TQueryFnData, @@ -132,12 +140,20 @@ export class Query< this._result = undefined as any; this.isResultRequsted = false; this.isEnabledOnResultDemand = config.enableOnDemand ?? false; + this.errorListeners = []; + this.doneListeners = []; this.hooks = 'hooks' in this.queryClient ? this.queryClient.hooks : undefined; + this.isLazy = this.config.lazy; - if ('queryFeatures' in queryClient && config.enableOnDemand == null) { - this.isEnabledOnResultDemand = - queryClient.queryFeatures.enableOnDemand ?? false; + if ('queryFeatures' in queryClient) { + if (this.config.lazy === undefined) { + this.isLazy = queryClient.queryFeatures.lazy ?? false; + } + if (config.enableOnDemand === undefined) { + this.isEnabledOnResultDemand = + queryClient.queryFeatures.enableOnDemand ?? false; + } } observable.deep(this, '_result'); @@ -149,31 +165,30 @@ export class Query< makeObservable(this); - this.options = this.queryClient.defaultQueryOptions({ - ...restOptions, - ...getDynamicOptions?.(this), - } as any); + const isQueryKeyDynamic = typeof queryKeyOrDynamicQueryKey === 'function'; + + this.options = this.queryClient.defaultQueryOptions(restOptions as any); this.options.structuralSharing = this.options.structuralSharing ?? false; - this.processOptions(this.options); + const getAllDynamicOptions = + getDynamicOptions || isQueryKeyDynamic + ? () => { + const freshDynamicOptions = { + ...getDynamicOptions?.(this), + }; - if (typeof queryKeyOrDynamicQueryKey === 'function') { - this.options.queryKey = queryKeyOrDynamicQueryKey(); - - reaction( - () => queryKeyOrDynamicQueryKey(), - (queryKey) => { - this.update({ - queryKey, - }); - }, - { - signal: this.abortController.signal, - delay: this.config.dynamicOptionsUpdateDelay, - }, - ); - } else { + if (isQueryKeyDynamic) { + freshDynamicOptions.queryKey = queryKeyOrDynamicQueryKey(); + } + + return freshDynamicOptions; + } + : undefined; + + if (getAllDynamicOptions) { + Object.assign(this.options, getAllDynamicOptions()); + } else if (!isQueryKeyDynamic) { this.options.queryKey = queryKeyOrDynamicQueryKey ?? this.options.queryKey ?? []; } @@ -184,6 +199,8 @@ export class Query< queryClient.getDefaultOptions().queries?.notifyOnChangeProps ?? 'all'; + this.processOptions(this.options); + this.queryObserver = new QueryObserver< TQueryFnData, TError, @@ -194,41 +211,73 @@ export class Query< this.updateResult(this.queryObserver.getOptimisticResult(this.options)); - this._observerSubscription = this.queryObserver.subscribe( - this.updateResult, - ); + if (this.isLazy) { + let dynamicOptionsDisposeFn: VoidFunction | undefined; - if (getDynamicOptions) { - reaction(() => getDynamicOptions(this), this.update, { - signal: this.abortController.signal, - delay: this.config.dynamicOptionsUpdateDelay, + onBecomeObserved(this, '_result', () => { + if (!this._observerSubscription) { + if (getAllDynamicOptions) { + this.update(getAllDynamicOptions()); + } + this._observerSubscription = this.queryObserver.subscribe( + this.updateResult, + ); + if (getAllDynamicOptions) { + dynamicOptionsDisposeFn = reaction( + getAllDynamicOptions, + this.update, + { + delay: this.config.dynamicOptionsUpdateDelay, + signal: config.abortSignal, + fireImmediately: true, + }, + ); + } + } }); - } - if (this.isEnabledOnResultDemand) { - reaction( - () => this.isResultRequsted, - (isRequested) => { - if (isRequested) { - this.update(getDynamicOptions?.(this) ?? {}); - } - }, - { + const cleanup = () => { + if (this._observerSubscription) { + dynamicOptionsDisposeFn?.(); + this._observerSubscription(); + this._observerSubscription = undefined; + dynamicOptionsDisposeFn = undefined; + config.abortSignal?.removeEventListener('abort', cleanup); + } + }; + + onBecomeUnobserved(this, '_result', cleanup); + config.abortSignal?.addEventListener('abort', cleanup); + } else { + if (isQueryKeyDynamic) { + reaction( + queryKeyOrDynamicQueryKey, + (queryKey) => this.update({ queryKey }), + { + signal: this.abortController.signal, + delay: this.config.dynamicOptionsUpdateDelay, + }, + ); + } + if (getDynamicOptions) { + reaction(() => getDynamicOptions(this), this.update, { signal: this.abortController.signal, - fireImmediately: true, - }, + delay: this.config.dynamicOptionsUpdateDelay, + }); + } + this._observerSubscription = this.queryObserver.subscribe( + this.updateResult, ); + this.abortController.signal.addEventListener('abort', this.handleAbort); } if (config.onDone) { - this.onDone(config.onDone); + this.doneListeners.push(config.onDone); } if (config.onError) { - this.onError(config.onError); + this.errorListeners.push(config.onError); } - this.abortController.signal.addEventListener('abort', this.handleAbort); - this.config.onInit?.(this); this.hooks?.onQueryInit?.(this); } @@ -298,12 +347,14 @@ export class Query< this.options = nextOptions; this.queryObserver.setOptions(this.options); + + if (this.isLazy) { + this.updateResult(this.queryObserver.getCurrentResult()); + } } private isEnableHolded = false; - private enableHolder = () => false; - private processOptions = ( options: QueryOptions, ) => { @@ -314,14 +365,14 @@ export class Query< // to do this, we hold the original value of the enabled option // and set enabled to false until the user requests the result (this.isResultRequsted) if (this.isEnabledOnResultDemand) { - if (this.isEnableHolded && options.enabled !== this.enableHolder) { + if (this.isEnableHolded && options.enabled !== enableHolder) { this.holdedEnabledOption = options.enabled; } if (this.isResultRequsted) { if (this.isEnableHolded) { options.enabled = - this.holdedEnabledOption === this.enableHolder + this.holdedEnabledOption === enableHolder ? undefined : this.holdedEnabledOption; this.isEnableHolded = false; @@ -329,18 +380,19 @@ export class Query< } else { this.isEnableHolded = true; this.holdedEnabledOption = options.enabled; - options.enabled = this.enableHolder; + options.enabled = enableHolder; } } }; public get result() { - if (!this.isResultRequsted) { + if (this.isEnabledOnResultDemand && !this.isResultRequsted) { runInAction(() => { this.isResultRequsted = true; }); + this.update({}); } - return this._result; + return this._result || this.queryObserver.getCurrentResult(); } /** @@ -348,6 +400,12 @@ export class Query< */ private updateResult(result: QueryObserverResult) { this._result = result; + + if (result.isSuccess && !result.error && result.fetchStatus === 'idle') { + this.doneListeners.forEach((fn) => fn(result.data!, void 0)); + } else if (result.error) { + this.errorListeners.forEach((fn) => fn(result.error!, void 0)); + } } async reset(params?: QueryResetParams) { @@ -366,42 +424,21 @@ export class Query< } as any); } - onDone(onDoneCallback: (data: TData, payload: void) => void): void { - reaction( - () => { - const { error, isSuccess, fetchStatus } = this._result; - return isSuccess && !error && fetchStatus === 'idle'; - }, - (isDone) => { - if (isDone) { - onDoneCallback(this._result.data!, void 0); - } - }, - { - signal: this.abortController.signal, - }, - ); + onDone(doneListener: QueryDoneListener): void { + this.doneListeners.push(doneListener); } - onError(onErrorCallback: (error: TError, payload: void) => void): void { - reaction( - () => this._result.error, - (error) => { - if (error) { - onErrorCallback(error, void 0); - } - }, - { - signal: this.abortController.signal, - }, - ); + onError(errorListener: QueryErrorListener): void { + this.errorListeners.push(errorListener); } protected handleAbort = () => { this._observerSubscription?.(); + this.doneListeners = []; + this.errorListeners = []; + this.queryObserver.destroy(); - this.isResultRequsted = false; let isNeedToReset = this.config.resetOnDestroy || this.config.resetOnDispose; @@ -421,10 +458,6 @@ export class Query< this.hooks?.onQueryDestroy?.(this); }; - destroy() { - this.abortController.abort(); - } - async start({ cancelRefetch, ...params @@ -440,6 +473,10 @@ export class Query< return await this.refetch({ cancelRefetch }); } + destroy() { + this.abortController?.abort(); + } + /** * @deprecated use `destroy`. This method will be removed in next major release */ diff --git a/src/query.types.ts b/src/query.types.ts index e75b356..ee69222 100644 --- a/src/query.types.ts +++ b/src/query.types.ts @@ -122,6 +122,13 @@ export interface QueryFeatures { * @see https://mobx.js.org/reactions.html#delay-_autorun-reaction_ */ dynamicOptionsUpdateDelay?: number; + /** + * **EXPERIMENTAL** + * + * Make all query reactions and subscriptions lazy. + * They exists only when query result is observed. + */ + lazy?: boolean; } /** @@ -150,6 +157,16 @@ export type MobxQueryConfigFromFn< TQueryKey extends QueryKey = QueryKey, > = QueryConfigFromFn; +export type QueryErrorListener = ( + error: TError, + payload: void, +) => void; + +export type QueryDoneListener = ( + data: TData, + payload: void, +) => void; + export interface QueryConfig< TQueryFnData = unknown, TError = DefaultError, @@ -185,8 +202,8 @@ export interface QueryConfig< query: Query, ) => void; abortSignal?: AbortSignal; - onDone?: (data: TData, payload: void) => void; - onError?: (error: TError, payload: void) => void; + onDone?: QueryDoneListener; + onError?: QueryErrorListener; /** * Dynamic query parameters, when result of this function changed query will be updated * (reaction -> setOptions) From 306b6724dc2bcab0ed3c0c7d8a1d61223cd8d8e4 Mon Sep 17 00:00:00 2001 From: Sergey Volkov Date: Sat, 23 Aug 2025 18:34:35 +0300 Subject: [PATCH 2/2] docs: update docs for queries and mutations (lazy option) --- docs/api/InfiniteQuery.md | 2 + docs/api/Mutation.md | 64 ++++++++---- docs/api/Query.md | 199 ++++++++++++++++++++++---------------- docs/api/QueryClient.md | 125 ++++++++++++++++-------- 4 files changed, 245 insertions(+), 145 deletions(-) diff --git a/docs/api/InfiniteQuery.md b/docs/api/InfiniteQuery.md index d4df6f5..00397e0 100644 --- a/docs/api/InfiniteQuery.md +++ b/docs/api/InfiniteQuery.md @@ -4,6 +4,8 @@ Class wrapper for [@tanstack-query/core infinite queries](https://tanstack.com/q [_See docs for Query_](/api/Query) +**All documentation about properties and methods of infinite query can be found in the original documentation [here](https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery)** + [Reference to source code](/src/infinite-query.ts) ## Usage diff --git a/docs/api/Mutation.md b/docs/api/Mutation.md index 6243921..a6aa97d 100644 --- a/docs/api/Mutation.md +++ b/docs/api/Mutation.md @@ -1,10 +1,12 @@ -# Mutation +# Mutation -Class wrapper for [@tanstack-query/core mutations](https://tanstack.com/query/latest/docs/framework/react/guides/mutations) with **MobX** reactivity +Class wrapper for [@tanstack-query/core mutations](https://tanstack.com/query/latest/docs/framework/react/guides/mutations) with **MobX** reactivity -[Reference to source code](/src/mutation.ts) +**All documentation about properties and methods of mutation can be found in the original documentation [here](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation)** -## Usage +[Reference to source code](/src/mutation.ts) + +## Usage Create an instance of `Mutation` with [`mutationFn`](https://tanstack.com/query/latest/docs/framework/react/guides/mutations) parameter @@ -36,12 +38,13 @@ const petCreateMutation = new Mutation({ const result = await petCreateMutation.mutate('Fluffy'); console.info(result.data, result.isPending, result.isError); -``` +``` + +## Built-in Features -## Built-in Features +### `abortSignal` option -### `abortSignal` option -This field is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Mutation` class +This field is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Mutation` class ```ts const abortController = new AbortController(); @@ -61,27 +64,46 @@ abortController.abort(); This is alternative for `destroy` method -### `destroy()` method -This method is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Mutation` class +### `lazy` option + +This option enables "lazy" mode of the mutation. That means that all subscriptions and reaction will be created only when you request result for this mutation. + +Example: + +```ts +const mutation = createMutation(queryClient, () => ({ + lazy: true, + mutationFn: async () => { + // api call + }, +})); + +// happens nothing +// no reactions and subscriptions will be created +``` + +### `destroy()` method + +This method is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Mutation` class -This is alternative for `abortSignal` option +This is alternative for `abortSignal` option -### method `mutate(variables, options?)` -Runs the mutation. (Works the as `mutate` function in [`useMutation` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation)) +### method `mutate(variables, options?)` -### hook `onDone()` -Subscribe when mutation has been successfully finished +Runs the mutation. (Works the as `mutate` function in [`useMutation` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation)) -### hook `onError()` -Subscribe when mutation has been finished with failure +### hook `onDone()` -### method `reset()` -Reset current mutation +Subscribe when mutation has been successfully finished -### property `result` -Mutation result (The same as returns the [`useMutation` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation)) +### hook `onError()` +Subscribe when mutation has been finished with failure +### method `reset()` +Reset current mutation +### property `result` +Mutation result (The same as returns the [`useMutation` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation)) diff --git a/docs/api/Query.md b/docs/api/Query.md index 2be0845..7c558f7 100644 --- a/docs/api/Query.md +++ b/docs/api/Query.md @@ -1,22 +1,27 @@ -# Query +# Query -Class wrapper for [@tanstack-query/core queries](https://tanstack.com/query/latest/docs/framework/react/guides/queries) with **MobX** reactivity +Class wrapper for [@tanstack-query/core queries](https://tanstack.com/query/latest/docs/framework/react/guides/queries) with **MobX** reactivity -[Reference to source code](/src/query.ts) +**All documentation about properties and methods of query can be found in the original documentation [here](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery)** -## Usage -There are two ways to use queries: +[Reference to source code](/src/query.ts) + +## Usage + +There are two ways to use queries: + +### 1. Automatic enabling\disabling of queries -### 1. Automatic enabling\disabling of queries This approach is suitable when we want the query to automatically make a request and process the data -depending on the availability of the necessary data. +depending on the availability of the necessary data. + +Example: -Example: ```ts const petName = observable.box(); const petQuery = new Query(queryClient, () => ({ - queryKey: ['pets', petName.get()] as const, + queryKey: ["pets", petName.get()] as const, enabled: !!petName.get(), // dynamic queryFn: async ({ queryKey }) => { const petName = queryKey[1]!; @@ -28,19 +33,22 @@ const petQuery = new Query(queryClient, () => ({ // petQuery is not enabled petQuery.options.enabled; -petName.set('Fluffy'); +petName.set("Fluffy"); // petQuery is enabled petQuery.options.enabled; ``` + ### 2. Manual control of query fetching -This approach is suitable when we need to manually load data using a query. -Example: +This approach is suitable when we need to manually load data using a query. + +Example: + ```ts const petQuery = new Query({ queryClient, - queryKey: ['pets', undefined as (string | undefined)] as const, + queryKey: ["pets", undefined as string | undefined] as const, enabled: false, queryFn: async ({ queryKey }) => { const petName = queryKey[1]!; @@ -50,11 +58,11 @@ const petQuery = new Query({ }); const result = await petQuery.start({ - queryKey: ['pets', 'Fluffy'], + queryKey: ["pets", "Fluffy"], }); console.log(result.data); -``` +``` ### Another examples @@ -63,7 +71,7 @@ Create an instance of `Query` with [`queryKey`](https://tanstack.com/query/lates ```ts const petsQuery = new Query({ queryClient, - abortSignal, // Helps you to automatically clean up query + abortSignal, // Helps you to automatically clean up query or use `lazy` option queryKey: ['pets'], queryFn: async ({ signal, queryKey }) => { const response = await petsApi.fetchPets({ signal }); @@ -77,17 +85,18 @@ console.log( petsQuery.result.data, petsQuery.result.isLoading ) -``` +``` ::: info This query is enabled by default! This means that the query will immediately call the `queryFn` function, i.e., make a request to `fetchPets` -This is the default behavior of queries according to the [**query documtation**](https://tanstack.com/query/latest/docs/framework/react/guides/queries) +This is the default behavior of queries according to the [**query documtation**](https://tanstack.com/query/latest/docs/framework/react/guides/queries) ::: -## Recommendations +## Recommendations + +### Don't forget about `abortSignal`s or `lazy` option -### Don't forget about `abortSignal`s When creating a query, subscriptions to the original queries and reactions are created. If you don't clean up subscriptions and reactions - memory leaks can occur. @@ -96,9 +105,10 @@ If you don't clean up subscriptions and reactions - memory leaks can occur. `queryKey` is not only a cache key but also a way to send necessary data for our API requests! Example + ```ts const petQuery = new Query(queryClient, () => ({ - queryKey: ['pets', 'Fluffy'] as const, + queryKey: ["pets", "Fluffy"] as const, queryFn: async ({ queryKey }) => { const petName = queryKey[1]!; const response = await petsApi.getPetByName(petName); @@ -107,10 +117,11 @@ const petQuery = new Query(queryClient, () => ({ })); ``` -## Built-in Features +## Built-in Features + +### `abortSignal` option -### `abortSignal` option -This field is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Query` class +This field is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Query` class ```ts const abortController = new AbortController(); @@ -131,37 +142,43 @@ abortController.abort() This is alternative for `destroy` method -### `destroy()` method -This method is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Query` class +### `destroy()` method + +This method is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Query` class -This is alternative for `abortSignal` option +This is alternative for `abortSignal` option + +### `enableOnDemand` option -### `enableOnDemand` option Query will be disabled until you request result for this query -Example: +Example: + ```ts const query = new Query({ //... - enableOnDemand: true + enableOnDemand: true, }); // happens nothing query.result.data; // from this code line query starts fetching data ``` -This option works as is if query will be "enabled", otherwise you should enable this query. +This option works as is if query will be "enabled", otherwise you should enable this query. + ```ts const query = new Query({ enabled: false, - enableOnDemand: true, + enableOnDemand: true, queryFn: () => {}, }); -query.result.data; // nothing happened because query is disabled. +query.result.data; // nothing happened because query is disabled. ``` -But if you set `enabled` as `true` and option `enableOnDemand` will be `true` too then query will be fetched only after user will try to get access to result. + +But if you set `enabled` as `true` and option `enableOnDemand` will be `true` too then query will be fetched only after user will try to get access to result. + ```ts const query = new Query({ enabled: true, - enableOnDemand: true, + enableOnDemand: true, queryFn: () => {}, }); ... @@ -171,117 +188,137 @@ const query = new Query({ query.result.data; // query starts execute the queryFn ``` -### dynamic `options` -Options which can be dynamically updated for this query +### dynamic `options` + +Options which can be dynamically updated for this query ```ts const query = new Query({ // ... options: () => ({ enabled: this.myObservableValue > 10, - queryKey: ['foo', 'bar', this.myObservableValue] as const, + queryKey: ["foo", "bar", this.myObservableValue] as const, }), queryFn: ({ queryKey }) => { const myObservableValue = queryKey[2]; - } + }, }); ``` -### dynamic `queryKey` -Works the same as dynamic `options` option but only for `queryKey` +### dynamic `queryKey` + +Works the same as dynamic `options` option but only for `queryKey` + ```ts const query = new Query({ // ... - queryKey: () => ['foo', 'bar', this.myObservableValue] as const, + queryKey: () => ["foo", "bar", this.myObservableValue] as const, queryFn: ({ queryKey }) => { const myObservableValue = queryKey[2]; - } + }, }); -``` -P.S. you can combine it with dynamic (out of box) `enabled` property +``` + +P.S. you can combine it with dynamic (out of box) `enabled` property + ```ts const query = new Query({ // ... - queryKey: () => ['foo', 'bar', this.myObservableValue] as const, + queryKey: () => ["foo", "bar", this.myObservableValue] as const, enabled: ({ queryKey }) => queryKey[2] > 10, queryFn: ({ queryKey }) => { const myObservableValue = queryKey[2]; - } + }, }); -``` - -### method `start(params)` +``` -Enable query if it is disabled then fetch the query. -This method is helpful if you want manually control fetching your query +### `lazy` option +This option enables "lazy" mode of the query. That means that all subscriptions and reaction will be created only when you request result for this query. -Example: +Example: ```ts +const query = createQuery(queryClient, () => ({ + lazy: true, + queryKey: ["foo", "bar"] as const, + queryFn: async () => { + // api call + }, +})); +// happens nothing +// no reactions and subscriptions will be created ``` +### method `start(params)` + +Enable query if it is disabled then fetch the query. +This method is helpful if you want manually control fetching your query + +Example: -### method `update()` +```ts -Update options for query (Uses [QueryObserver](https://tanstack.com/query/latest/docs/reference/QueriesObserver).setOptions) +``` -### hook `onDone()` +### method `update()` -Subscribe when query has been successfully fetched data +Update options for query (Uses [QueryObserver](https://tanstack.com/query/latest/docs/reference/QueriesObserver).setOptions) -### hook `onError()` +### hook `onDone()` -Subscribe when query has been failed fetched data +Subscribe when query has been successfully fetched data -### method `invalidate()` +### hook `onError()` -Invalidate current query (Uses [queryClient.invalidateQueries](https://tanstack.com/query/latest/docs/reference/QueryClient/#queryclientinvalidatequeries)) +Subscribe when query has been failed fetched data -### method `reset()` +### method `invalidate()` -Reset current query (Uses [queryClient.resetQueries](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientresetqueries)) +Invalidate current query (Uses [queryClient.invalidateQueries](https://tanstack.com/query/latest/docs/reference/QueryClient/#queryclientinvalidatequeries)) -### method `setData()` +### method `reset()` -Set data for current query (Uses [queryClient.setQueryData](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata)) +Reset current query (Uses [queryClient.resetQueries](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientresetqueries)) -### property `isResultRequsted` -Any time when you trying to get access to `result` property this field sets as `true` -This field is needed for `enableOnDemand` option -This property if **observable** +### method `setData()` -### property `result` +Set data for current query (Uses [queryClient.setQueryData](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata)) -**Observable** query result (The same as returns the [`useQuery` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery)) +### property `isResultRequsted` +Any time when you trying to get access to `result` property this field sets as `true` +This field is needed for `enableOnDemand` option +This property if **observable** +### property `result` +**Observable** query result (The same as returns the [`useQuery` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery)) -## About `enabled` -All queries are `enabled` (docs can be found [here](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery)) by default, but you can set `enabled` as `false` or use dynamic value like `({ queryKey }) => !!queryKey[1]` -You can use `update` method to update value for this property or use dynamic options construction (`options: () => ({ enabled: !!this.observableValue })`) +## About `enabled` +All queries are `enabled` (docs can be found [here](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery)) by default, but you can set `enabled` as `false` or use dynamic value like `({ queryKey }) => !!queryKey[1]` +You can use `update` method to update value for this property or use dynamic options construction (`options: () => ({ enabled: !!this.observableValue })`) -## About `refetchOnWindowFocus` and `refetchOnReconnect` +## About `refetchOnWindowFocus` and `refetchOnReconnect` They **will not work if** you will not call `mount()` method manually of your `QueryClient` instance which you send for your queries, all other cases dependents on query `stale` time and `enabled` properties. -Example: +Example: ```ts -import { hashKey, QueryClient } from '@tanstack/query-core'; +import { hashKey, QueryClient } from "@tanstack/query-core"; export const queryClient = new QueryClient({ defaultOptions: { queries: { throwOnError: true, queryKeyHashFn: hashKey, - refetchOnWindowFocus: 'always', - refetchOnReconnect: 'always', + refetchOnWindowFocus: "always", + refetchOnReconnect: "always", staleTime: 5 * 60 * 1000, retry: (failureCount, error) => { - if ('status' in error && Number(error.status) >= 500) { + if ("status" in error && Number(error.status) >= 500) { return failureCount < 3; } return false; @@ -297,4 +334,4 @@ export const queryClient = new QueryClient({ queryClient.mount(); ``` -If you work with [`QueryClient`](/api/QueryClient) then calling `mount()` is not needed. +If you work with [`QueryClient`](/api/QueryClient) then calling `mount()` is not needed. diff --git a/docs/api/QueryClient.md b/docs/api/QueryClient.md index 0b31bd3..481be83 100644 --- a/docs/api/QueryClient.md +++ b/docs/api/QueryClient.md @@ -1,12 +1,12 @@ # QueryClient - An enhanced version of [TanStack's Query QueryClient](https://tanstack.com/query/v5/docs/reference/QueryClient). -Adds specialized configurations for library entities like [`Query`](/api/Query) or [`Mutation`](/api/Mutation). +Adds specialized configurations for library entities like [`Query`](/api/Query) or [`Mutation`](/api/Mutation). + +[Reference to source code](/src/query-client.ts) -[Reference to source code](/src/query-client.ts) +## API Signature -## API Signature ```ts import { QueryClient } from "@tanstack/query-core"; @@ -15,10 +15,12 @@ class QueryClient extends QueryClient { } ``` -## Configuration +## Configuration + When creating an instance, you can provide: + ```ts -import { DefaultOptions } from '@tanstack/query-core'; +import { DefaultOptions } from "@tanstack/query-core"; interface QueryClientConfig { defaultOptions?: DefaultOptions & { @@ -29,49 +31,86 @@ interface QueryClientConfig { } ``` -## Key methods and properties +## Key methods and properties + +### `queryFeatures` + +Features configurations exclusively for [`Query`](/api/Query)/[`InfiniteQuery`](/api/InfiniteQuery) + +Example: +```ts +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + lazy: true, + enableOnDemand: true, + // resetOnDestroy: false, + // dynamicOptionsUpdateDelay: undefined, + throwOnError: true, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: 0, + retry: false, + }, + }, +}); +``` + +### `mutationFeatures` + +Features configurations exclusively for [`Mutation`](/api/Mutation) + +Example: +```ts +export const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + // invalidateByKey: true, + // resetOnDestroy: true, + lazy: true, + }, + }, +}); +``` + +### `hooks` -### `queryFeatures` -Features configurations exclusively for [`Query`](/api/Query)/[`InfiniteQuery`](/api/InfiniteQuery) +Entity lifecycle events. Available hooks: -### `mutationFeatures` -Features configurations exclusively for [`Mutation`](/api/Mutation) +| Hook | Description | +| ---------------------- | ------------------------------------------------------------------- | +| onQueryInit | Triggered when a [`Query`](/api/Query) is created | +| onInfiniteQueryInit | Triggered when a [`InfiniteQuery`](/api/InfiniteQuery) is created | +| onMutationInit | Triggered when a [`Mutation`](/api/Mutation) is created | +| onQueryDestroy | Triggered when a [`Query`](/api/Query) is destroyed | +| onInfiniteQueryDestroy | Triggered when a [`InfiniteQuery`](/api/InfiniteQuery) is destroyed | +| onMutationDestroy | Triggered when a [`Mutation`](/api/Mutation) is destroyed | -### `hooks` -Entity lifecycle events. Available hooks: +## Inheritance -| Hook | Description | -|---|---| -| onQueryInit | Triggered when a [`Query`](/api/Query) is created | -| onInfiniteQueryInit | Triggered when a [`InfiniteQuery`](/api/InfiniteQuery) is created | -| onMutationInit | Triggered when a [`Mutation`](/api/Mutation) is created | -| onQueryDestroy | Triggered when a [`Query`](/api/Query) is destroyed | -| onInfiniteQueryDestroy | Triggered when a [`InfiniteQuery`](/api/InfiniteQuery) is destroyed | -| onMutationDestroy | Triggered when a [`Mutation`](/api/Mutation) is destroyed | +`QueryClient` inherits all methods and properties from [QueryClient](https://tanstack.com/query/v5/docs/reference/QueryClient), including: -## Inheritance -`QueryClient` inherits all methods and properties from [QueryClient](https://tanstack.com/query/v5/docs/reference/QueryClient), including: -- [`getQueryData()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientgetquerydata) +- [`getQueryData()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientgetquerydata) - [`setQueryData()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientsetquerydata) - [`invalidateQueries()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientinvalidatequeries) - [`prefetchQuery()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientprefetchquery) - [`cancelQueries()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientcancelqueries) -- And others ([see official documentation](https://tanstack.com/query/v5/docs/reference/QueryClient)) +- And others ([see official documentation](https://tanstack.com/query/v5/docs/reference/QueryClient)) ## Usage Example ```ts -import { QueryClient } from 'mobx-tanstack-query'; +import { QueryClient } from "mobx-tanstack-query"; // Create a client with custom hooks const client = new QueryClient({ hooks: { onQueryInit: (query) => { - console.log('[Init] Query:', query.queryKey); + console.log("[Init] Query:", query.queryKey); }, onMutationDestroy: (mutation) => { - console.log('[Destroy] Mutation:', mutation.options.mutationKey); - } + console.log("[Destroy] Mutation:", mutation.options.mutationKey); + }, }, defaultOptions: { queries: { @@ -79,31 +118,31 @@ const client = new QueryClient({ }, mutations: { invalidateByKey: true, - } + }, }, }); // Use standard QueryClient methods -const data = client.getQueryData(['todos']); +const data = client.getQueryData(["todos"]); ``` ## When to Use? -Use `QueryClient` if you need: + +Use `QueryClient` if you need: + - Customization of query/mutation lifecycle - Tracking entity initialization/destruction events - Advanced configuration for `MobX`-powered queries and mutations. +## Persistence -## Persistence - -If you need persistence you can use built-in TanStack query feature like `createSyncStoragePersister` or `createAsyncStoragePersister` -Follow this guide from original TanStack query documentation: -https://tanstack.com/query/latest/docs/framework/react/plugins/createSyncStoragePersister#api +If you need persistence you can use built-in TanStack query feature like `createSyncStoragePersister` or `createAsyncStoragePersister` +Follow this guide from original TanStack query documentation: +https://tanstack.com/query/latest/docs/framework/react/plugins/createSyncStoragePersister#api +### Example of implementation -### Example of implementation - -1. Install TanStack's persister dependencies: +1. Install TanStack's persister dependencies: ::: code-group @@ -121,13 +160,13 @@ yarn add @tanstack/query-async-storage-persister @tanstack/react-query-persist-c ::: -2. Create `QueryClient` instance and attach "persistence" feature to it +2. Create `QueryClient` instance and attach "persistence" feature to it ```ts{2,3,4,6,10} import { QueryClient } from "mobx-tanstack-query"; import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; import { persistQueryClient } from '@tanstack/react-query-persist-client'; -import { compress, decompress } from 'lz-string' +import { compress, decompress } from 'lz-string' export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } }, @@ -142,4 +181,4 @@ persistQueryClient({ }), maxAge: Infinity, }); -``` \ No newline at end of file +```