Mobx Query
Queries

Defining Queries

How to define queries using QueryMany and QueryOne — options, hooks, arguments, and helper methods.

mobx-query provides two primary query classes for fetching data:

ClassReturnsUse case
QueryManyEntity arrayFetching lists (all folders, recent documents, etc.)
QueryOneSingle entityFetching a single entity by ID or unique criteria

Both share the same options interface, hooks pattern, and helper methods — they differ only in whether they return one entity or many.

QueryMany

Use QueryMany to define queries that return a list of entities:

folders.store.ts
import { QueryMany } from "@mobx-query/core";
import { Folder, type FolderData } from "./folder.entity";

export class FoldersStore {
  readonly foldersQuery = new QueryMany({
    entity: Folder,
    queryKey: () => ["all"],
    queryFn: async () => {
      const res = await fetch("/api/folders");
      return res.json() as Promise<FolderData[]>;
    },
  });
}

QueryOne

Use QueryOne to fetch a single entity by its identifier:

folders.store.ts
import { QueryOne } from "@mobx-query/core";
import { Folder, type FolderData } from "./folder.entity";

export class FoldersStore {
  readonly folderByIdQuery = new QueryOne({
    entity: Folder,
    queryKey: () => ["byId"],
    queryFn: async (folderId: string) => {
      const res = await fetch(`/api/folders/${folderId}`);
      return res.json() as Promise<FolderData>;
    },
  });
}

Options

Both QueryMany and QueryOne accept the same core options:

OptionTypeRequiredDescription
entityEntity constructorThe entity class this query hydrates (e.g. Folder)
queryKey() => unknown[]A function returning a static identifier array for this query
queryFn(args, context) => TData | Promise<TData>The data-fetching function
staleTimenumberTime in ms before data is considered stale
gcTimenumberTime in ms before inactive query data is garbage collected
enabledboolean | ((meta, args) => boolean)Controls whether the query executes (for useQuery only)
networkModeNetworkModeTanStack Query network mode ('online', 'always', 'offlineFirst')
retryboolean | number | ((failureCount, error) => bool)Retry behavior for failed queries
retryDelaynumber | ((failureCount, error) => number)Delay between retries in ms

queryFn receives two arguments: args (the runtime arguments passed to the hook) and context (your registered MQClientContext — including queryClient and any custom dependencies you've registered). If your query takes no arguments, the first parameter can be omitted.

The queryKey Function

The queryKey function should return a static logical identifier for the query. Arguments are appended automatically — you don't include them in the key.

// ✅ Correct — static key, args are appended automatically
queryKey: () => ["folderDocuments"];

// ❌ Wrong — don't include dynamic values in queryKey
queryKey: () => ["folderDocuments", this.folderId];

The only exception is when defining queries on entity instances, where you may include this.id to scope the query to a specific entity:

// ✅ On an entity — scoping to this instance
queryKey: () => ["folderDocuments", this.id];

See Where to Define Queries below for more on this pattern.

Hooks

useSuspenseQuery(args)

Available on: QueryMany, QueryOne

Suspends the component until data is available. Returns a guaranteed result — no loading states to handle:

// QueryMany — returns Folder[]
const folders = foldersQuery.useSuspenseQuery();

// QueryOne — returns Folder
const folder = folderByIdQuery.useSuspenseQuery(folderId);

Use this inside a React <Suspense> boundary:

<Suspense fallback={<Spinner />}>
  <FolderList />
</Suspense>

useDeferredQuery(args)

Available on: QueryMany, QueryOne

A variant of useSuspenseQuery that wraps args with React's useDeferredValue. When args change, React keeps showing the previous results while fetching the new ones in the background — preventing the UI from flashing a loading state.

Best for: search inputs, filter changes, navigation between items.

const search = useSearch(); // e.g. from a URL search param
const documents = documentsQuery.useDeferredQuery(search);
// Previous results stay visible while the new query loads

useQuery(args, meta)

Available on: QueryMany, QueryOne

A non-suspense variant. Returns entities immediately (empty array / null until data loads). Supports the enabled option to conditionally skip the query.

const FolderList = observer(() => {
  const { rootStore } = useMQ();
  const folders = rootStore.folders.foldersQuery.useQuery();

  // `folders` is [] while loading, then populated
  return (
    <ul>
      {folders.map((folder) => (
        <li key={folder.id}>{folder.displayTitle}</li>
      ))}
    </ul>
  );
});

The second argument meta is passed to the enabled callback if one is defined:

readonly foldersQuery = new QueryMany<
  void,
  { isEnabled: boolean } | void,
  typeof Folder
>({
  entity: Folder,
  queryKey: () => ["preview"],
  queryFn: async () => { /* ... */ },
  enabled: (meta) => (!meta ? true : meta.isEnabled),
});

// Usage:
const folders = foldersQuery.useQuery(undefined, { isEnabled: isVisible });

Query Arguments

The first type parameter of QueryMany / QueryOne defines the shape of the arguments passed to queryFn and all hooks:

// No arguments (default: void)
readonly foldersQuery = new QueryMany({
  entity: Folder,
  queryKey: () => ["all"],
  queryFn: async () => { /* ... */ },
});
foldersQuery.useSuspenseQuery(); // no args needed

// With arguments
readonly folderByIdQuery = new QueryOne({
  entity: Folder,
  queryKey: () => ["byId"],
  queryFn: async (folderId: string) => { /* ... */ },
});
folderByIdQuery.useSuspenseQuery("folder-123"); // typed as string

Arguments are automatically appended to the query key (see How It Works), so different arguments produce different cache entries.

Helper Methods

All query classes provide methods for working with the cache outside of React hooks.

prefetch(args)

Pre-fetches data and populates the cache before a component renders. Useful for route loaders or hover prefetching:

// In a route loader
await foldersQuery.prefetch();

// On hover
onMouseEnter={() => folderByIdQuery.prefetch(folderId)}

ensureData(args)

Similar to prefetch, but only fetches if the cache is empty. Returns the cached data if it exists:

const entityIds = await foldersQuery.ensureData();

invalidate(args)

Manually invalidates a specific query cache entry. If the query is currently active (mounted in a component), it immediately refetches:

foldersQuery.invalidate();
folderByIdQuery.invalidate("folder-123");

invalidate() computes the query hash from the args and targets the exact cache entry. If the query has been garbage collected, the invalidation is silently ignored.

setQueryData(data, args)

Manually injects data into the query cache without fetching. The data is hydrated through the normal entity collection pipeline:

// Seed a list query with existing data
foldersQuery.setQueryData(serverFolders);

// Seed a single entity query
folderByIdQuery.setQueryData(folderRecord, "folder-123");

Useful for:

  • Hydrating the cache with data from a parent query (avoiding redundant fetches)
  • Server-side rendering / initial data
  • Optimistic updates in custom mutation handlers

getQueryIds(args) (QueryMany only)

Returns the current array of entity IDs stored in the cache:

const ids = foldersQuery.getQueryIds();
// => ["id-1", "id-2", "id-3"]

setQueryIds(ids, args) (QueryMany only)

Directly sets the entity ID array in the cache. Used for optimistic updates where you need to manipulate the query result directly:

foldersQuery.setQueryIds(["id-1", "id-2", "id-4"]);

getQueryKey(args) (QueryMany only)

Returns the full constructed query key. Useful when integrating directly with TanStack Query:

const queryKey = foldersQuery.getQueryKey();
// => ["Folder", "__query__many__", "all"]

useIsFetching(args)

A React hook that returns true when the query is currently fetching (initial or background refetch):

const isFetching = foldersQuery.useIsFetching();

Where to Define Queries

Queries can be defined in two places, each serving a different purpose.

In a Store — Top-Level Queries

Define queries in a store when they represent top-level collections or global data:

export class FoldersStore {
  readonly foldersQuery = new QueryMany({
    entity: Folder,
    queryKey: () => ["all"],
    queryFn: async () => {
      /* fetch all folders */
    },
  });

  readonly folderByIdQuery = new QueryOne({
    entity: Folder,
    queryKey: () => ["byId"],
    queryFn: async (folderId: string) => {
      /* fetch one folder */
    },
  });
}

On an Entity — Instance-Scoped Queries

Define queries on an entity when the data is scoped to a specific entity instance. Use this.id in the queryKey to create per-instance cache entries:

export class Folder extends Entity<FolderData> {
  id: string = crypto.randomUUID();

  // Each Folder instance gets its own distinct query:
  // ["Document", "__query__many__", "folderDocuments", "folder-1"]
  // ["Document", "__query__many__", "folderDocuments", "folder-2"]
  readonly documentsQuery = new QueryMany({
    entity: Document,
    queryKey: () => ["folderDocuments", this.id],
    queryFn: async (filter: FilterOptions) => {
      const res = await fetch(`/api/folders/${this.id}/documents`);
      return res.json();
    },
  });
}

When a query is defined on an entity, it naturally becomes instance-scoped — each entity gets its own cache entry. The query is co-located with the data it relates to, keeping your code organized and discoverable.

On this page