import { Store, inject } from '@servicetitan/react-ioc'; import { AxiosError } from 'axios'; import forEach from 'lodash/forEach'; import { action, computed, makeObservable } from 'mobx'; import type { MutationApiOptions } from './mutation.api'; import { MutationApi } from './mutation.api'; import { QueryClientStore } from './query-client.store'; import type { QueryApiOptions, IRefreshOnMountStore } from './query.api'; import { QueryApi } from './query.api'; import type { MutationKey, QueryKey } from '@tanstack/query-core'; /** * Abstract base class for creating MobX stores with TanStack Query integration. * Provides declarative data fetching with automatic caching, synchronization, and state management. * * For new stores, consider the `@queryStore` decorator + `query()`/`mutation()` helpers * from `@servicetitan/tanstack-query-mobx/composable` — it offers the same capabilities * with less boilerplate and more flexibility (composition over inheritance). * * @template T - The type of data returned by the primary query * @template TError - The error type (default: AxiosError) * * @remarks * **Key Features:** * - Extend this class and implement the `queryOptions` getter to configure your primary query * - The store automatically subscribes to the query client during initialization * - Query state is exposed as MobX observables (`data`, `isLoading`, `isError`, `initialized`, etc.) * - Supports multiple queries via {@link addQuery} and mutations via {@link addMutation} * - Automatic query deduplication and caching across component instances * - Data-driven refetching: queries automatically refetch when `queryKey` dependencies change * * **Observable State:** * - `data` - The query result (deep cloned to prevent mutations) * - `initialized` - True after first query completion (success or error) * - `isLoading` - True when fetching with no data yet * - `isFetching` - True whenever fetching (including refetches) * - `isError` - True when query failed * - `error` - The error object if failed * * **Lifecycle:** * 1. Constructor: Create the store * 2. `initialize()`: Setup queries and mutations (called by react-ioc) * 3. Query automatically runs based on `enabled` option (default: true) * 4. `dispose()`: Cleanup on unmount (called by react-ioc) * * @see {@link QueryApiOptions} for query configuration options * @see {@link QueryApi} for individual query management * @see {@link MutationApi} for mutation operations * @see README.md "Quick Start" for setup instructions * @see README.md "Common Patterns" for usage examples * * @example Basic store with single query * ```typescript * @injectable() * export class JobsStore extends QueryApiStore { * @inject(JobsApi) private api?: JobsApi; * * get queryOptions(): QueryApiOptions { * return { * queryKey: ['scheduling', 'jobs'], * queryFn: async () => (await this.api?.getJobs())?.data ?? [], * staleTime: 1000 * 60 * 5, // 5 minutes * }; * } * } * ``` * * @example Store with multiple queries and mutations * ```typescript * @injectable() * export class JobsStore extends QueryApiStore { * @inject(JobsApi) private api?: JobsApi; * @observable selectedJobId = 0; * * // Primary query * get queryOptions(): QueryApiOptions { * return { * queryKey: ['scheduling', 'jobs'], * queryFn: async () => (await this.api?.getJobs())?.data ?? [], * }; * } * * // Additional query - refetches when selectedJobId changes * jobDetails = this.addQuery(() => ({ * queryKey: ['scheduling', 'job', this.selectedJobId], * queryFn: async () => (await this.api?.getJob(this.selectedJobId))?.data, * enabled: this.selectedJobId > 0, * })); * * // Mutation with automatic invalidation * deleteJob = this.addMutation(() => ({ * mutationFn: async (arg) => await this.api?.deleteJob(arg.id), * invalidatedQueries: [['scheduling', 'jobs']], // Refetch jobs after delete * })); * } * ``` * * @example Using in a React component * ```typescript * const JobsList = observer(() => { * const [store] = useDependencies(JobsStore); * * if (!store.initialized) return ; * if (store.isError) return ; * if (store.isLoading) return ; * * return ( *
* {store.data?.map(job => )} * {store.isFetching && } *
* ); * }); * ``` */ export abstract class QueryApiStore extends Store implements IRefreshOnMountStore { private static callSuperDispose = Symbol('Calling super.Dispose is mandatory'); query = new QueryApi(() => this.queryOptions); overrideOptions?: () => Partial>; storesToRefreshOnMount?: () => (IRefreshOnMountStore | undefined)[] = undefined; @inject(QueryClientStore) protected queryClientStore?: QueryClientStore; protected disposables: (() => void)[] = []; private mutations: Map MutationApiOptions), MutationApi> = new Map(); private queries: Map QueryApiOptions), QueryApi> = new Map(); constructor() { super(); makeObservable(this); } /** * Becomes true after the first query attempt settles without any active fetch or invalidation. * Use this package-specific readiness flag to know when initial store setup has finished. */ @computed get initialized() { return this.query.initialized; } /** * Mirrors TanStack Query's `isPending`. * True while the query has no settled result yet, including disabled queries that have never run. */ @computed get isPending() { return this.query.isPending; } /** * Mirrors TanStack Query's `isLoading`. * True only while the first fetch is in flight (`isFetching && isPending`). */ @computed get isLoading() { return this.query.isLoading; } /** * Mirrors TanStack Query's `isFetching`. * True whenever the query function is executing, including the initial fetch and background refetches. */ @computed get isFetching() { return this.query.isFetching; } /** * Mirrors TanStack Query's `isRefetching`. * True during a background refetch after the query has already moved past the initial pending state. */ @computed get isRefetching() { return this.query.isRefetching; } /** * Mirrors TanStack Query's `isSuccess` derived status flag. */ @computed get isSuccess() { return this.query.isSuccess; } /** * Mirrors TanStack Query's `isError` derived status flag. */ @computed get isError() { return this.query.isError; } /** * Mirrors TanStack Query's `error` value. Defaults to `null` when no error is present. */ @computed get error() { return this.query.error; } /** * Mirrors TanStack Query's `refetch`. * @returns Promise that resolves with the query result */ @computed get refetch() { return this.query.refetch; } /** * Function to invalidate and trigger a refetch of the query. * @param options - Optional invalidation options (debounce, dedupe) */ @computed get invalidate() { return this.query.invalidate; } /** * Function to cancel an in-flight query request. */ @computed get cancel() { return this.query.cancel; } /** * The last query data snapshot exposed by this store. * Successful results are deep-cloned before assignment so consumers do not mutate cached data by accident. */ @computed get data() { return this.query.data; } /** * Query configuration options. Must be implemented by subclasses. * This getter is reactive - changes trigger query refetch. * * @example * ```typescript * get queryOptions(): QueryApiOptions { * return { * queryKey: ['scheduling', 'jobs', this.filters], * queryFn: async () => (await this.api?.getJobs(this.filters))?.data ?? [], * enabled: this.hasRequiredData, * }; * } * ``` */ @computed get queryOptions(): QueryApiOptions { return {} as QueryApiOptions; } get refreshOnMount() { return () => { this.query.refreshOnMount(); this.queries.forEach((query: QueryApi) => query.refreshOnMount()); }; } get refreshStoresOnMount() { return this.query.refreshStoresOnMount; } @action initialize(): Promise { this.mutations.forEach((mutation: MutationApi) => mutation.setup(this.queryClientStore)); this.queries.forEach((query: QueryApi) => query.setup(this.queryClientStore)); this.query.setup(this.queryClientStore, this.overrideOptions, this.storesToRefreshOnMount); if (this.storesToRefreshOnMount?.()) { this.refreshStoresOnMount?.(); } return Promise.resolve(); } dispose() { forEach(this.disposables, disposer => disposer()); this.mutations.forEach((mutation: MutationApi) => mutation.dispose()); this.queries.forEach((query: QueryApi) => query.dispose()); this.query.dispose(); // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents return undefined as typeof QueryApiStore.callSuperDispose & never; } /** * Updates the cached query data without refetching from the server. * Triggers the onSuccess callback if configured. * * @param data - The new data to set * * @example * ```typescript * updateJobLocally(updatedJob: Job) { * const jobs = this.data ?? []; * const newJobs = jobs.map(j => j.id === updatedJob.id ? updatedJob : j); * this.updateQueryData(newJobs); * } * ``` */ updateQueryData = (data: T) => { return this.query.updateQueryData(data); }; createMapKey = (key?: MutationKey | QueryKey) => { return key?.join('-') ?? ''; }; /** * Adds a secondary query to the store. Most stores only need the primary `queryOptions` getter — * use `addQuery` only when your store needs to fetch from multiple endpoints. * * @template T - The type of data returned by this query * @param options - Function that returns query options (reactive) * @param key - Optional query key for retrieval via getQuery() * @param runSetup - Whether to immediately setup the query (default: false, waits for initialize()) * @returns The QueryApi instance for this query * * @example * ```typescript * statsQuery = this.addQuery(() => ({ * queryKey: ['scheduling', 'stats', this.filters], * queryFn: async () => (await this.api?.getStats(this.filters))?.data, * })); * * get stats() { * return this.statsQuery.data; * } * ``` */ addQuery = ( options: () => QueryApiOptions, key?: QueryKey, runSetup = false ) => { const mapKey = key ? this.createMapKey(key) : options; let newQuery = this.queries.get(mapKey) as QueryApi | undefined; if (!newQuery) { newQuery = new QueryApi(options); this.queries.set(mapKey, newQuery as any); if (runSetup) { newQuery.setup(this.queryClientStore); } } return newQuery; }; /** * Retrieves a previously added query by its key. * * @template T - The type of data returned by the query * @param key - The query key used when calling addQuery() * @returns The QueryApi instance, or undefined if not found */ getQuery = (key: QueryKey) => { return this.queries.get(this.createMapKey(key)) as QueryApi | undefined; }; /** * Adds a mutation to the store for data updates (create, update, delete operations). * * @template TData - The return type of the mutation function * @template TVariables - The type of variables passed to the mutation * @param options - Function that returns mutation options (reactive) * @param key - Optional mutation key for retrieval via getMutation() * @param runSetup - Whether to immediately setup the mutation (default: false, waits for initialize()) * @returns The MutationApi instance * * @example * ```typescript * createJob = this.addMutation(() => ({ * mutationFn: async (request) => (await this.api?.createJob(request))?.data, * invalidatedQueries: [['scheduling', 'jobs']], // Refetch jobs list after creation * onSuccess: (job) => toast.success(`Created ${job.title}`), * })); * * handleCreate = async (request: CreateJobRequest) => { * await this.createJob.runMutation(request); * }; * ``` */ addMutation = ( options: () => MutationApiOptions, key?: MutationKey, runSetup = false ) => { const mapKey = key ? this.createMapKey(key) : options; let newMutation = this.mutations.get(mapKey) as MutationApi | undefined; if (!newMutation) { newMutation = new MutationApi(options); this.mutations.set(mapKey, newMutation as any); if (runSetup) { newMutation.setup(this.queryClientStore); } } return newMutation; }; /** * Retrieves a previously added mutation by its key. * * @template TData - The return type of the mutation * @template TVariables - The type of variables for the mutation * @param key - The mutation key used when calling addMutation() * @returns The MutationApi instance, or undefined if not found */ getMutation = (key: MutationKey) => { return this.mutations.get(this.createMapKey(key)) as MutationApi | undefined; }; }