# @servicetitan/tanstack-query-mobx

TanStack Query integration for MobX stores with `@servicetitan/react-ioc` dependency injection. Provides declarative data fetching, caching, and state management with automatic query deduplication.

## Installation

```bash
npm install @servicetitan/tanstack-query-mobx
```

## Getting Started

### 1. Provide the Query Client

```tsx
import { getQueryClient } from '@servicetitan/tanstack-query-mobx/composable';

export const App = provide({
    singletons: [getQueryClient()],
})(observer(() => <YourApp />));
```

### 2. Create a Store

Two approaches — both work with the same query client. The **composable** approach is recommended for new stores.

#### Composable (`@servicetitan/tanstack-query-mobx/composable`) — Recommended

The `@queryStore` decorator + `query()`/`mutation()` factories. All queries are typed class properties with consistent access patterns. Favors composition over inheritance.

```tsx
import { queryStore, query, mutation } from '@servicetitan/tanstack-query-mobx/composable';

@queryStore
class JobsStore extends Store {
    @inject(JobsApi) private api?: JobsApi;
    @observable selectedJobId = 0;

    jobs = query<Job[]>(() => ({
        queryKey: ['scheduling', 'jobs'],
        queryFn: async () => (await this.api?.getJobs())?.data ?? [],
    }));

    jobDetails = query<JobDetails>(() => ({
        queryKey: ['scheduling', 'job', this.selectedJobId],
        queryFn: async () => (await this.api?.getJob(this.selectedJobId))?.data,
        enabled: this.selectedJobId > 0,
    }));

    deleteJob = mutation<void, { id: number }>(() => ({
        mutationFn: async (arg) => await this.api?.deleteJob(arg.id),
        invalidatedQueries: [['scheduling', 'jobs']],
    }));
}
```

Runtime queries and mutations (created after initialization, e.g., in response to user actions):

```tsx
@queryStore
class DetailsStore extends Store {
    @inject(DetailsApi) private api?: DetailsApi;
    private queries = setupRuntimeQueries();

    loadDetails(id: number) {
        return this.queries.add<Details>(() => ({
            queryKey: ['details', id],
            queryFn: () => this.api?.getDetails(id),
        }), ['details', id]); // deduplicates by key
    }
}
```

Refresh on mount (invalidate stale queries when the store initializes):

```tsx
@queryStore
class JobsStore extends Store {
    jobs = query<Job[]>(() => ({ ... }));
    techs = query<Tech[]>(() => ({ ... }));

    // Ensure fresh data from external store caches when this store mounts
    refresh = refreshOnMount(['business-units', 'list'], ['techs', 'list']);
}
```

**Why `extends Store`?** Stores must extend `Store` from `@servicetitan/react-ioc` to hook into the react-ioc lifecycle — `initialize()` runs when the provider mounts, `dispose()` runs when it unmounts. The `@queryStore` decorator uses these hooks to auto-wire queries and clean up subscriptions.

**What `@queryStore` handles automatically:**
- Injects `QueryClientStore` (no `@inject(QueryClientStore)` needed)
- Applies `@injectable()` (no separate decorator needed)
- Auto-discovers `QueryApi`, `MutationApi`, `RuntimeQueries`, `RuntimeMutations`,
  and `RefreshOnMountDef` properties and wires their lifecycle
- Chains consumer `initialize()` and `dispose()` methods
- Safe with deep inheritance — prevents double setup/dispose

#### Inheritance (`@servicetitan/tanstack-query-mobx`)

Extend `QueryApiStore<T>` with a primary query via `queryOptions` getter, plus optional secondary queries via `addQuery()`.

```tsx
import { QueryApiStore, QueryApiOptions } from '@servicetitan/tanstack-query-mobx';

@injectable()
class JobsStore extends QueryApiStore<Job[]> {
    @inject(JobsApi) private api?: JobsApi;

    get queryOptions(): QueryApiOptions<Job[]> {
        return {
            queryKey: ['scheduling', 'jobs'],
            queryFn: async () => (await this.api?.getJobs())?.data ?? [],
        };
    }

    deleteJob = this.addMutation<void, { id: number }>(() => ({
        mutationFn: async (arg) => await this.api?.deleteJob(arg.id),
        invalidatedQueries: [['scheduling', 'jobs']],
    }));
}
```

See [QueryApiStore](./src/utils/query-api.store.ts) JSDoc for full API including `addQuery()`, `addMutation()`, and lifecycle details.

### 3. Use in Components

```tsx
const [store] = useDependencies(JobsStore);

// Composable — access via named query
const { data, isLoading } = store.jobs;

// Inheritance — primary query proxied to store
const { data, isLoading } = store;
```

## Key Concepts

**Query Keys** — Arrays that uniquely identify queries. The first element is a namespace that scopes the cache (e.g., `['scheduling', 'jobs', ...]`). Subsequent elements identify the resource and parameters. Changes trigger automatic refetch:

```tsx
queryKey: ['scheduling', 'jobs'],           // module + resource
queryKey: ['scheduling', 'jobs', jobId],    // refetches when jobId changes
```

**Observable State** — Each query exposes `data`, `initialized`, `isLoading`, `isFetching`, `isError`, `error`

**Stale Time** — Default 10 minutes. Set `staleTime: 0` for always fresh, `Infinity` for never stale.

**Data-Driven Queries** — MobX reactions automatically track `queryKey` dependencies when using a function. Use `enabled` to conditionally fetch:

```tsx
jobs = query<Job[]>(() => ({
    queryKey: ['scheduling', 'jobs', this.filters],
    queryFn: async () => (await this.api?.getJobs(this.filters))?.data ?? [],
    enabled: this.hasRequiredData,
}));
```

> **Note:** Use a function `() => ({ ... })` when options depend on observables. A plain object `{ ... }` is evaluated once at class field initialization and does not react to changes.

## Extending and Overriding

### Composable — shared factory functions

Share query definitions across stores with factory functions. Pass a deps function so MobX tracks observables reactively:

```tsx
// shared/queries/jobs-queries.ts
export const jobsQuery = (
    deps: () => { api?: JobsApi; teamId: number }
) => query<Job[]>(() => {
    const { api, teamId } = deps();
    return {
        queryKey: ['scheduling', 'jobs', teamId],
        queryFn: async () => (await api?.getJobs(teamId))?.data ?? [],
        enabled: teamId > 0,
    };
});

// Different stores reuse the same definition
@queryStore
class SchedulingStore extends Store {
    @inject(JobsApi) private api?: JobsApi;
    @observable teamId = 0;

    jobs = jobsQuery(() => ({ api: this.api, teamId: this.teamId }));
}
```

### Composable — `@computed` getter with `...super`

Use a `@computed` getter on the base class, override in subclasses:

```tsx
@queryStore
class JobsStore extends Store {
    @inject(JobsApi) protected api?: JobsApi;

    jobs = query<Job[]>(() => this.jobsQueryOptions);

    @computed
    get jobsQueryOptions(): QueryApiOptions<Job[]> {
        return {
            queryKey: ['scheduling', 'jobs'],
            queryFn: async () => (await this.api?.getJobs())?.data ?? [],
        };
    }
}

// Override the getter — same query instance, different options
@queryStore
class FilteredJobsStore extends JobsStore {
    @observable filters = {};

    get jobsQueryOptions(): QueryApiOptions<Job[]> {
        return {
            ...super.jobsQueryOptions,
            queryKey: ['scheduling', 'jobs', this.filters],
        };
    }
}
```

### Inheritance — `queryOptions` getter with `...super`

```tsx
@injectable()
class JobsStore extends QueryApiStore<Job[]> {
    @inject(JobsApi) protected api?: JobsApi;

    get queryOptions(): QueryApiOptions<Job[]> {
        return {
            queryKey: ['scheduling', 'jobs'],
            queryFn: async () => (await this.api?.getJobs())?.data ?? [],
        };
    }
}

// Override — spread super, tweak what you need
@injectable()
class FilteredJobsStore extends JobsStore {
    @observable filters = {};

    get queryOptions(): QueryApiOptions<Job[]> {
        return {
            ...super.queryOptions,
            queryKey: ['scheduling', 'jobs', this.filters],
        };
    }
}
```

## API Reference

### Composable (`@servicetitan/tanstack-query-mobx/composable`)

| Export | Usage | Purpose |
|--------|-------|---------|
| `@queryStore` | Class decorator | Auto-wires lifecycle, injects `QueryClientStore`, applies `@injectable()` |
| `query(options)` | Class fields | Create a `QueryApi` — plain object or `() => options` (use function when options depend on observables) |
| `mutation(options)` | Class fields | Create a `MutationApi` — plain object or `() => options` (use function when options depend on observables) |
| `setupRuntimeQueries()` | Class fields | Container for queries created after initialization — `.add()` and `.get()` |
| `setupRuntimeMutations()` | Class fields | Container for mutations created after initialization — `.add()` and `.get()` |
| `refreshOnMount(...targets)` | Class fields | Invalidate `QueryApi` instances and/or `QueryKey`s on store initialization |

All helpers are declarative class fields — the decorator auto-discovers and wires them. No `this` argument needed.

### Inheritance (`@servicetitan/tanstack-query-mobx`)

| Export | Purpose |
|--------|---------|
| `QueryApiStore<T>` | Base class — extend and override `queryOptions` getter for the primary query |
| `QueryApi<T>` | Individual query manager — `data`, `isLoading`, `invalidate()`, `refetch()`, etc. |
| `MutationApi<T, V>` | Mutation manager — `runMutation()`, `isPending`, `isSuccess`, `isError` |
| `QueryApiOptions<T>` | Query configuration extending TanStack's `QueryObserverOptions` |
| `MutationApiOptions<T, V>` | Mutation configuration extending TanStack's `MutationObserverOptions` |
| `QueryClientStore` | Manages the TanStack `QueryClient` instance |
| `getQueryClient()` | Factory for providing `QueryClientStore` in a `Provider`. Pass a `QueryClientConfig` for custom defaults (local clients only) |

`QueryApiStore` provides `addQuery()` for secondary queries, `addMutation()` for mutations, and proxies primary query state (`data`, `isLoading`, `initialized`, etc.) to the store. See [QueryApiStore JSDoc](./src/utils/query-api.store.ts) for full API.

### Shared Clients (Micro-Frontends)

For micro-frontend architectures where multiple app instances share query cache:

- `'page'` — Reference-counted, disposes when last consumer unmounts
- `'app'` — Persists until browser refresh

See [getQueryClient](./src/utils/get-query-client.ts) and [QueryApiOptions.globalClient](./src/utils/query.api.ts) JSDoc.

## Testing

Use `ContainerBuilder` from `@servicetitan/tanstack-query-mobx/test` — it handles `QueryClientStore` setup, store initialization, and waits for all queries to settle. Queries with `globalClient` still use the injected test client.

```tsx
import { ContainerBuilder } from '@servicetitan/tanstack-query-mobx/test';

const { container, initialize } = new ContainerBuilder()
    .add(JobsStore)
    .add(JobsApi)
    .build();

jest.spyOn(container.get(JobsApi), 'getJobs').mockResolvedValue({
    data: [{ id: 1, title: 'Plumbing' }],
});

await initialize();
expect(container.get(JobsStore).jobs.data).toHaveLength(1);
```

See the full [Testing Guide](./src/test/TESTING.md) for `ContainerBuilder` API, skipping queries, mock values, manual setup, and query state reference.

## Source Code

All classes and functions have comprehensive TSDoc comments. Explore the source for detailed documentation:

- [query-store-decorator/](./src/utils/query-store-decorator/) — `@queryStore` decorator, `query()`, `mutation()`, `refreshOnMount()`, `setupRuntimeQueries()`, `setupRuntimeMutations()`
- [query-api.store.ts](./src/utils/query-api.store.ts) — `QueryApiStore` base class
- [query.api.ts](./src/utils/query.api.ts) — `QueryApi`, `QueryApiOptions`
- [mutation.api.ts](./src/utils/mutation.api.ts) — `MutationApi`, `MutationApiOptions`
- [query-client.store.ts](./src/utils/query-client.store.ts) — `QueryClientStore`
- [get-query-client.ts](./src/utils/get-query-client.ts) — `getQueryClient()` factory

## License

Part of the `@servicetitan/anvil-uikit-contrib` monorepo.
