Defining Entities
Learn how to define entity models — the building blocks of mobx-query.
Entities are the core building blocks of mobx-query. An Entity is a MobX-observable class that represents a single record from your data source — a row in a database table, an object from a REST API, or any identifiable piece of server state.
Every entity in mobx-query extends the abstract Entity base class and must implement two things:
- An
idfield — the unique identifier for this record. - A
hydrate(data)method — the function that maps raw data onto the entity's observable properties.
Basic Entity Structure
Here's a minimal entity definition:
import { Entity } from "@mobx-query/core";
import { observable } from "mobx";
interface FolderData {
id: string;
name: string;
description: string;
isPinned: boolean;
}
export class Folder extends Entity<FolderData> {
id: string = crypto.randomUUID();
@observable accessor name: string = "";
@observable accessor description: string = "";
@observable accessor isPinned: boolean = false;
hydrate(data: FolderData) {
this.id = data.id;
this.name = data.name;
this.description = data.description;
this.isPinned = data.isPinned;
}
}The Entity base class is generic and accepts two type parameters:
Entity<TData, TEntityId = string>;| Parameter | Description |
|---|---|
TData | The shape of the raw data passed to hydrate(). Can be a union type if you need to handle different data shapes (e.g. create vs. select). |
TEntityId | The type of the entity's id field. Must be string or number. Defaults to string. |
The id Field
Every entity must have an id property. This is how mobx-query identifies and deduplicates entities across queries. When two queries return data with the same id, mobx-query will hydrate the same entity instance rather than creating duplicates.
export class Folder extends Entity<FolderData> {
id: string = crypto.randomUUID();
// ...
}The id field should not be decorated with @observable. It is the
immutable identity of the entity and should never change after creation.
Why Generate IDs on the Client Side
You may have noticed that the id is initialized with crypto.randomUUID() instead of being left empty. This is intentional and critical for optimistic updates.
When you create a new entity via CreateMutation, mobx-query immediately instantiates the entity and adds it to the EntityCollection before the server request completes. For this to work, the entity needs a valid, unique id at instantiation time.
If you waited for the server to assign an ID:
- The entity couldn't be inserted into the identity map.
- Other queries couldn't reference it.
- The UI would flicker as the entity disappears and reappears when the server response arrives.
By generating the ID client-side, the entity is immediately available in the UI, fully reactive, and identifiable. When the server confirms the creation, nothing changes from the user's perspective — the entity was already there.
Always generate IDs on the client side for entities that participate in
CreateMutation. Use
crypto.randomUUID() for UUID-based IDs or the built-in generateEntityId()
helper for sequential IDs.
The generateEntityId Helper
mobx-query provides a built-in utility for generating deterministic, client-only entity IDs:
import { generateEntityId } from "@mobx-query/core";
import { Folder } from "./folder.entity";
const id = generateEntityId(Folder);
// => "entityClientOnlyId_Folder_1"
const id2 = generateEntityId(Folder);
// => "entityClientOnlyId_Folder_2"generateEntityId maintains an internal incrementing counter per entity type. It produces IDs with a recognizable prefix (entityClientOnlyId_) that makes them easy to identify in devtools and logs. These IDs are guaranteed to be unique within a single client session.
This is useful when:
- You need an ID before
CreateMutationis called. - Your backend will replace the client-generated ID with its own (e.g. an auto-incremented integer).
- You want to distinguish client-created entities from server-confirmed ones in devtools.
Compound IDs
Sometimes your data model doesn't have a single id field. For example, a join table might use a compound key of (documentId, labelId). In this case, you need to derive a synthetic id from the compound fields.
The simplest approach is to concatenate the fields into a single string:
import { Entity } from "@mobx-query/core";
interface DocumentLabelData {
documentId: string;
labelId: string;
}
export class DocumentLabel extends Entity<DocumentLabelData> {
id: string = "";
documentId: string = "";
labelId: string = "";
hydrate(data: DocumentLabelData) {
this.documentId = data.documentId;
this.labelId = data.labelId;
this.id = `${data.documentId}:${data.labelId}`;
}
}When using compound IDs, make sure the concatenation produces a
deterministic, unique string for each combination. Using a separator like
: or _ prevents ambiguity (e.g. IDs "1"+"23" vs "12"+"3" would both
produce "123" without a separator).
You can also create a static helper method on the entity for consistency:
export class DocumentLabel extends Entity<DocumentLabelData> {
// ...
static createId(documentId: string, labelId: string) {
return `${documentId}:${labelId}`;
}
hydrate(data: DocumentLabelData) {
this.documentId = data.documentId;
this.labelId = data.labelId;
this.id = DocumentLabel.createId(data.documentId, data.labelId);
}
}This pattern keeps the ID generation logic centralized and reusable when you need to look up entities by their compound key.
The hydrate Method
The hydrate() method is the single entry point for mapping raw data onto your entity's properties. It is called:
- On initial fetch — when a query returns data and creates a new entity.
- On refetch — when a query refetches and the entity already exists (updating its properties with fresh data).
- On
setQueryData— when you manually inject data into the query cache.
hydrate(data: FolderData) {
this.id = data.id;
this.name = data.name;
this.description = data.description;
this.isPinned = data.isPinned;
}Handling Multiple Data Shapes
In practice, the data shape for creating an entity often differs from the shape returned by selecting it (e.g. the select shape includes server-generated timestamps). You can handle this with a union type and a discriminant check:
interface SelectFolderData {
id: string;
name: string;
description: string;
isPinned: boolean;
createdAt: Date;
updatedAt: Date;
}
interface CreateFolderData {
name: string;
description: string;
isPinned: boolean;
}
export class Folder extends Entity<SelectFolderData | CreateFolderData> {
id: string = crypto.randomUUID();
createdAt: Date = new Date();
@observable accessor updatedAt: Date = new Date();
@observable accessor name: string = "";
@observable accessor description: string = "";
@observable accessor isPinned: boolean = false;
hydrate(data: SelectFolderData | CreateFolderData) {
if ("id" in data) {
// Full server data — set all fields including server-generated ones
this.id = data.id;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this.name = data.name;
this.description = data.description;
this.isPinned = data.isPinned;
} else {
// Partial create data — only set user-provided fields
this.name = data.name;
this.description = data.description;
this.isPinned = data.isPinned;
}
}
}This pattern works well because:
- Query results always include
idand server timestamps → the'id' in databranch runs. CreateMutationinput typically lacks server fields → theelsebranch runs, and the entity keeps its client-generatedidand default timestamps.
Hydrating Related Entities
If your query returns nested data (e.g. a document with its labels), you can hydrate related entities inside hydrate() using fragment queries:
import { QueryFragmentMany } from "@mobx-query/core";
import { Label } from "./label.entity";
export class Document extends Entity<DocumentData> {
id: string = crypto.randomUUID();
@observable accessor title: string = "";
readonly labelsQuery = new QueryFragmentMany({
entity: Label,
queryKey: () => ["documentLabels", this.id],
queryFn: async () => {
const res = await fetch(`/api/documents/${this.id}/labels`);
return res.json();
},
});
hydrate(data: DocumentData) {
this.id = data.id;
this.title = data.title;
// Hydrate nested labels if they were included in the query
if (data.labels) {
this.labelsQuery.setQueryData(data.labels);
}
}
}Entity State
Every entity has a state property that tracks the lifecycle of the currently active mutation. The state is a simple observable string enum:
type EntityState = "pending" | "confirmed" | "failed";| State | Meaning |
|---|---|
confirmed | Default state. The entity is in sync with the server (or no mutation is in progress). |
pending | A mutation (CreateMutation, UpdateMutation, or DeleteMutation) is currently in flight. |
failed | The most recent mutation failed. |
The state transitions are managed automatically by the OptimisticMutationStrategy:
confirmed ─── mutation starts ───► pending
│
┌────────┴────────┐
▼ ▼
confirmed failed
(on success) (on error)Using State in the UI
The state property is @observable, so you can use it reactively in your components to show loading indicators, error states, or disable interactions:
import { observer } from "mobx-react-lite";
const FolderItem = observer(({ folder }: { folder: Folder }) => {
return (
<div style={{ opacity: folder.state === "pending" ? 0.6 : 1 }}>
<span>{folder.name}</span>
{folder.state === "pending" && <Spinner />}
{folder.state === "failed" && <span>Failed to save</span>}
</div>
);
});The entity's state reflects the mutation lifecycle, not the query
lifecycle. Queries have their own loading/error states managed by TanStack
Query. The entity state tells you whether a write operation
(create/update/delete) is in progress or failed.
Dirty Tracking
mobx-query automatically tracks whether an entity has been locally modified since it was last hydrated from server data. This is managed through the isDirty property.
How It Works
- When
hydrate()runs, the entity is marked as "hydrated" and change tracking begins. - Whenever a
@observable accessorproperty changes, theEntitybase class intercepts the change via MobX'sobserve(). - The original value is deep-cloned and stored in an internal snapshot map (only for the first change per field).
isDirtyis set totrue.
const folder = // ... entity from a query
console.log(folder.isDirty); // false
folder.name = "New Name";
console.log(folder.isDirty); // trueFields That Are Ignored
The state and isDirty properties themselves are excluded from change tracking to prevent infinite loops. Only your custom @observable accessor fields trigger dirty tracking.
Resetting to Original Values
Call entity.reset() to restore all modified fields to their original server values:
folder.name = "New Name";
folder.description = "New Description";
console.log(folder.isDirty); // true
folder.reset();
console.log(folder.name); // original server value
console.log(folder.isDirty); // falseThe reset() method:
- Iterates over all stored snapshots.
- Deep-clones each original value and assigns it back.
- Handles
ObservableArray(using.replace()) andObservableMap(using.clear()+.merge()) correctly. - Clears the snapshot map and sets
isDirtyback tofalse.
Using Dirty Tracking for Mutations
UpdateMutation integrates with dirty tracking out of the box. It automatically skips mutations if the entity is not dirty, preventing unnecessary server requests:
readonly updateMutation = new UpdateMutation({
entity: Folder,
instance: this,
mutationFn: async () => {
await fetch(`/api/folders/${this.id}`, {
method: 'PATCH',
body: JSON.stringify({
name: this.name,
description: this.description,
}),
headers: { 'Content-Type': 'application/json' },
});
},
});
// In your entity or component:
this.name = 'Updated Name';
this.updateMutation.mutate(); // ✅ Runs — entity is dirty
this.updateMutation.mutate(); // ⏩ Skipped — entity is clean after successOn a successful mutation, the dirty state is automatically cleared via _clearDirty(). On a failed mutation with the 'rollback' error strategy (the default), reset() is called automatically, reverting the entity to its original values.
Using isDirty in the UI
The isDirty property is @observable, making it perfect for reactive UI patterns like enabling/disabling save buttons:
import { observer } from "mobx-react-lite";
const FolderEditor = observer(({ folder }: { folder: Folder }) => {
const save = folder.updateMutation.useMutation();
return (
<div>
<input
value={folder.name}
onChange={(e) => {
folder.name = e.target.value;
}}
/>
<button onClick={save} disabled={!folder.isDirty}>
Save
</button>
<button onClick={() => folder.reset()} disabled={!folder.isDirty}>
Discard Changes
</button>
</div>
);
});Declaring Observable Properties
Use the @observable accessor syntax (TC39 standard decorators) for properties that should be reactive and participate in dirty tracking:
@observable accessor name: string = '';
@observable accessor isPinned: boolean = false;
@observable accessor tags: string[] = [];Properties that should not be tracked (like id, createdAt, or any immutable field) should be plain class fields:
id: string = crypto.randomUUID();
createdAt: Date = new Date();Computed Properties
Use @computed get for derived values that depend on observable properties:
import { computed, observable } from "mobx";
export class Folder extends Entity<FolderData> {
@observable accessor name: string = "";
@computed get displayTitle() {
return this.name || "Untitled folder";
}
}Computed values are cached by MobX and only re-evaluated when their dependencies change — making them ideal for derived display values, formatted strings, or filtered collections.
Full Entity Example
Here's a complete, real-world entity with queries, mutations, computed properties, and dirty tracking:
import {
Entity,
UpdateMutation,
DeleteMutation,
QueryMany,
} from "@mobx-query/core";
import { action, computed, observable } from "mobx";
export class Folder extends Entity<SelectFolderData | CreateFolderData> {
// Immutable identity — generated client-side for optimistic creates
id: string = crypto.randomUUID();
// Server-generated timestamps — not observable (won't trigger dirty)
createdAt: Date = new Date();
// Observable properties — tracked for dirty detection
@observable accessor updatedAt: Date = new Date();
@observable accessor name: string = "";
@observable accessor description: string = "";
@observable accessor isPinned: boolean = false;
@observable accessor color: string | null = null;
@observable accessor icon: string | null = null;
// Computed — cached, derived from observables
@computed get displayTitle() {
return this.name || "Untitled folder";
}
// Sub-query — documents belonging to this folder
readonly documentsQuery = new QueryMany({
entity: Document,
queryKey: () => ["folderDocuments", this.id],
queryFn: async () => {
const res = await fetch(`/api/folders/${this.id}/documents`);
return res.json();
},
});
// Mutations — declared on the entity for co-located logic
readonly updateMutation = new UpdateMutation({
entity: Folder,
instance: this,
mutationFn: async () => {
await fetch(`/api/folders/${this.id}`, {
method: "PATCH",
body: JSON.stringify({
name: this.name,
description: this.description,
color: this.color,
icon: this.icon,
isPinned: this.isPinned,
}),
headers: { "Content-Type": "application/json" },
});
},
invalidationStrategy: "all-entity-queries",
});
readonly deleteMutation = new DeleteMutation({
entity: Folder,
instance: this,
mutationFn: async () => {
await fetch(`/api/folders/${this.id}`, { method: "DELETE" });
},
});
// Actions — modify observable state and trigger mutations
@action onPinFolder() {
this.isPinned = !this.isPinned;
this.updateMutation.mutate();
}
// Hydrate — maps raw data onto observable properties
hydrate(data: SelectFolderData | CreateFolderData) {
if ("id" in data) {
this.id = data.id;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this.name = data.name ?? "";
this.description = data.description ?? "";
this.isPinned = data.isPinned ?? false;
this.color = data.color ?? null;
this.icon = data.icon ?? null;
} else {
this.name = data.name ?? "";
this.description = data.description ?? "";
this.isPinned = data.isPinned ?? false;
this.color = data.color ?? null;
this.icon = data.icon ?? null;
}
}
}Notice how the entity is self-contained — it owns its queries, mutations,
and actions. This co-location pattern keeps related logic together and makes
entities easy to reason about. When you receive a Folder instance from any
query, all of its behavior is immediately available.