Introduction to MobX with TypeScript
MobX is a battle-tested state management library that makes reactivity simple and predictable. When combined with TypeScript, it becomes an even more powerful tool—giving you compile-time guarantees about your state shape, action signatures, and computed derivations. This tutorial walks you through building strongly typed MobX applications from the ground up, covering everything from basic setup to advanced generic patterns.
By the end of this guide, you'll understand how to leverage TypeScript's type system to eliminate entire categories of runtime bugs in your MobX stores, and how to write state management code that is self-documenting, refactor-safe, and a pleasure to maintain.
Why Strong Typing Matters in MobX
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →MobX relies heavily on decorators, observable annotations, and automatic dependency tracking. Without TypeScript, a simple typo in an action name or a misunderstanding of what a computed value returns can lead to subtle bugs that only surface at runtime. Strong typing addresses this by:
- Catching property misnames at compile time—no more referencing
store.todoswhen the field is actually calledstore.tasks - Enforcing action signatures so you never pass the wrong arguments to a state mutation
- Making computed values explicit about their return types, preventing unexpected
undefinedvalues propagating through your UI - Enabling IDE autocompletion and inline documentation for every observable, action, and computed property
- Protecting refactoring safety—rename a field and TypeScript immediately flags every broken reference across your entire codebase
In short, TypeScript transforms MobX from a flexible but loosely-typed reactive system into a rigorous, predictable state container that scales effortlessly across large teams and codebases.
Setting Up MobX with TypeScript
First, install the required packages. You'll need mobx for the core reactive engine, and if you're using React, mobx-react-lite for hook-based bindings:
npm install mobx mobx-react-lite
npm install --save-dev typescript @types/node
Your tsconfig.json should enable strict mode and support decorators if you plan to use the legacy decorator syntax. For modern MobX (v6+), decorators are optional but recommended via makeObservable or makeAutoObservable:
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"experimentalDecorators": true,
"useDefineForClassFields": true,
"jsx": "react-jsx"
}
}
Note that useDefineForClassFields set to true aligns with modern ECMAScript semantics and is critical for MobX class-based stores to work correctly with TypeScript.
Core Concepts with TypeScript
MobX revolves around four fundamental pillars: observable state, actions, computed values, and reactions. Let's see how TypeScript enhances each one.
Observable State
Observable state is the source of truth that MobX tracks. With TypeScript, you define the shape explicitly using interfaces or type aliases. This ensures every part of your application agrees on what the state looks like.
import { makeAutoObservable, observable } from "mobx";
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}
class TodoStore {
todos: Todo[] = [];
filter: "all" | "active" | "completed" = "all";
isLoading: boolean = false;
constructor() {
makeAutoObservable(this);
}
}
const store = new TodoStore();
export { store, TodoStore };
Here, TypeScript guarantees that todos is always an array of Todo objects, filter is exactly one of three string literal values, and isLoading is a boolean. Any attempt to assign store.todos = "invalid" is caught immediately by the compiler.
Actions
Actions are functions that modify observable state. TypeScript enforces their parameter types and return types, ensuring you never accidentally mutate state from outside an action context (when using strict mode) and never pass malformed data into a mutation.
import { action } from "mobx";
class TodoStore {
todos: Todo[] = [];
isLoading: boolean = false;
constructor() {
makeAutoObservable(this);
}
addTodo = (title: string): Todo => {
const newTodo: Todo = {
id: Date.now(),
title,
completed: false,
createdAt: new Date(),
};
this.todos.push(newTodo);
return newTodo;
};
toggleTodo = (id: number): void => {
const todo = this.todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
};
removeTodo = (id: number): void => {
this.todos = this.todos.filter((t) => t.id !== id);
};
setFilter = (filter: "all" | "active" | "completed"): void => {
this.filter = filter;
};
}
Notice how addTodo explicitly declares its return type as Todo. TypeScript will verify that the returned object matches the Todo interface. The setFilter action restricts its argument to the union type, preventing invalid filter strings from ever entering the store.
Computed Values
Computed values derive new information from existing observable state. They are pure functions that cache their results and only recompute when their dependencies change. TypeScript infers their return types automatically, but you can (and should) annotate them explicitly for clarity.
import { computed } from "mobx";
class TodoStore {
todos: Todo[] = [];
filter: "all" | "active" | "completed" = "all";
constructor() {
makeAutoObservable(this);
}
get filteredTodos(): Todo[] {
switch (this.filter) {
case "active":
return this.todos.filter((t) => !t.completed);
case "completed":
return this.todos.filter((t) => t.completed);
default:
return this.todos;
}
}
get activeTodoCount(): number {
return this.todos.filter((t) => !t.completed).length;
}
get completedTodoCount(): number {
return this.todos.filter((t) => t.completed).length;
}
get hasTodos(): boolean {
return this.todos.length > 0;
}
get completionPercentage(): number {
if (this.todos.length === 0) return 0;
return (this.completedTodoCount / this.todos.length) * 100;
}
}
TypeScript ensures that filteredTodos always returns a Todo[], that activeTodoCount is a number, and that hasTodos is a boolean. These type guarantees propagate to every component that consumes these computed values.
Reactions
Reactions are side-effect runners that execute when observable data changes. The most common are autorun, reaction, and when. TypeScript helps here by typing the disposer function and the reaction callbacks.
import { autorun, reaction, when } from "mobx";
// autorun: runs immediately and whenever its tracked dependencies change
const disposer = autorun(() => {
console.log(`Active todos: ${store.activeTodoCount}`);
// TypeScript knows activeTodoCount is a number
});
// Stop the autorun later
disposer();
// reaction: runs only when the specifically tracked value changes
const reactionDisposer = reaction(
() => store.todos.length,
(count: number) => {
console.log(`Todo count changed to ${count}`);
// TypeScript ensures count is number
}
);
// when: runs once when a condition becomes true, then auto-disposes
when(
() => store.completedTodoCount > 10,
(): void => {
console.log("Congratulations! You've completed more than 10 todos.");
}
);
The type system tracks the dependency graph automatically. If you reference a property that doesn't exist on the store, TypeScript flags it immediately.
Typing Stores and State Models
For larger applications, you'll want to split state into multiple stores and compose them. TypeScript interfaces shine here by defining contracts between stores.
import { makeAutoObservable } from "mobx";
// Define clear interfaces for each store domain
interface User {
id: string;
name: string;
email: string;
avatarUrl: string | null;
}
interface AuthState {
currentUser: User | null;
isAuthenticated: boolean;
loginError: string | null;
}
class AuthStore implements AuthState {
currentUser: User | null = null;
loginError: string | null = null;
constructor() {
makeAutoObservable(this);
}
get isAuthenticated(): boolean {
return this.currentUser !== null;
}
login = async (email: string, password: string): Promise => {
try {
this.loginError = null;
const user: User = await api.login(email, password);
this.currentUser = user;
return user;
} catch (error) {
this.loginError = error instanceof Error ? error.message : "Login failed";
throw error;
}
};
logout = (): void => {
this.currentUser = null;
this.loginError = null;
};
}
// Root store that composes domain stores
class RootStore {
auth: AuthStore;
todos: TodoStore;
constructor() {
this.auth = new AuthStore();
this.todos = new TodoStore();
}
}
const rootStore = new RootStore();
export { rootStore, RootStore, AuthStore, TodoStore };
By implementing the AuthState interface, the AuthStore class guarantees that all required observable fields exist. TypeScript will error if you forget to initialize currentUser or loginError. The RootStore composition is fully typed—accessing rootStore.auth.currentUser gives you complete type information all the way down.
Using makeObservable and makeAutoObservable with TypeScript
Modern MobX (v6+) encourages using makeObservable or makeAutoObservable inside the constructor rather than decorators. TypeScript works seamlessly with both approaches.
makeAutoObservable
This is the simplest approach—it automatically marks all own properties as observable, all getters as computed, and all functions as actions:
class CounterStore {
count: number = 0;
multiplier: number = 2;
constructor() {
makeAutoObservable(this);
}
increment = (): void => {
this.count++;
};
get doubled(): number {
return this.count * this.multiplier;
}
}
TypeScript fully understands that count and multiplier are number, increment returns void, and doubled returns number. No extra type annotations are needed—the compiler infers everything from the class definition.
makeObservable (Explicit)
When you need finer control, use makeObservable to explicitly declare which fields are observable, computed, or actions:
import { makeObservable, observable, computed, action, flow } from "mobx";
class UserStore {
users: User[] = [];
selectedUserId: string | null = null;
constructor() {
makeObservable(this, {
users: observable,
selectedUserId: observable,
selectedUser: computed,
fetchUsers: flow,
selectUser: action,
});
}
get selectedUser(): User | undefined {
return this.users.find((u) => u.id === this.selectedUserId);
}
*fetchUsers(): Generator, void, User[]> {
this.users = yield api.fetchUsers();
}
selectUser = (id: string): void => {
this.selectedUserId = id;
};
}
The explicit approach gives TypeScript more information about which members participate in reactivity. The flow annotation tells MobX that fetchUsers is an asynchronous generator-based action, and TypeScript correctly types the yielded values.
Typing Reactions and Autorun
When reactions grow complex, wrapping them in typed utility functions keeps your codebase maintainable. Here's how to create a strongly typed persistence layer using MobX reactions:
import { reaction, toJS } from "mobx";
interface PersistenceConfig {
key: string;
store: T;
selector: (store: T) => unknown;
storage?: Storage;
}
function persistState(
config: PersistenceConfig
): () => void {
const { key, store, selector, storage = localStorage } = config;
// Load initial state
const saved = storage.getItem(key);
if (saved) {
try {
const parsed = JSON.parse(saved);
Object.assign(store, parsed);
} catch {
storage.removeItem(key);
}
}
// React to changes and persist
const disposer = reaction(
() => selector(store),
() => {
const snapshot = toJS(selector(store));
storage.setItem(key, JSON.stringify(snapshot));
},
{ fireImmediately: false }
);
return disposer;
}
// Usage with full type safety
const dispose = persistState({
key: "todo-store",
store: rootStore.todos,
selector: (store) => ({
todos: store.todos,
filter: store.filter,
}),
});
The generic T extends object constraint ensures persistState only accepts object stores. TypeScript infers the selector's return type and guarantees that Object.assign targets compatible properties. The returned disposer function is typed as () => void, reminding you to call it during cleanup.
MobX with React and TypeScript
The mobx-react-lite package provides hooks that integrate MobX observables into React components. TypeScript ensures your components only access properties that actually exist on the store.
import { observer } from "mobx-react-lite";
import { rootStore } from "./stores";
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onRemove: (id: number) => void;
}
const TodoItem: React.FC = observer(({ todo, onToggle, onRemove }) => {
return (
onToggle(todo.id)}
/>
{todo.title}
);
});
const TodoList: React.FC = observer(() => {
const store = rootStore.todos;
return (
Active: {store.activeTodoCount} | Completed: {store.completedTodoCount}
{store.filteredTodos.map((todo) => (
))}
);
});
The observer wrapper preserves full type inference. store.filteredTodos is known to be Todo[], so todo in the map callback is typed as Todo. If you try to access store.nonexistentField, TypeScript immediately reports an error.
Typing the Observer HOC and Hooks
For class components or custom hooks, you can explicitly type the store injection:
import { observer } from "mobx-react-lite";
import { useObserver } from "mobx-react-lite";
// Custom hook that selects a slice of the store
function useTodoStats(): { active: number; completed: number; total: number } {
const store = rootStore.todos;
return useObserver(() => ({
active: store.activeTodoCount,
completed: store.completedTodoCount,
total: store.todos.length,
}));
}
// Typed component using the hook
const TodoStats: React.FC = observer(() => {
const stats = useTodoStats();
return (
Total: {stats.total}
Active: {stats.active}
Completed: {stats.completed}
);
});
The explicit return type on useTodoStats serves as documentation and a compile-time contract. Any mismatch between the hook's return value and the component's usage is caught immediately.
Advanced Patterns
Generic Stores
For reusable store patterns, generics allow you to create type-safe store factories. Here's a generic CRUD store that works with any entity type:
import { makeAutoObservable } from "mobx";
interface Identifiable {
id: string | number;
}
class CrudStore {
items: T[] = [];
selectedId: T["id"] | null = null;
isLoading: boolean = false;
constructor() {
makeAutoObservable(this);
}
get selectedItem(): T | undefined {
return this.items.find((item) => item.id === this.selectedId);
}
add = (item: T): void => {
this.items.push(item);
};
update = (id: T["id"], updates: Partial): void => {
const index = this.items.findIndex((item) => item.id === id);
if (index !== -1) {
this.items[index] = { ...this.items[index], ...updates };
}
};
remove = (id: T["id"]): void => {
this.items = this.items.filter((item) => item.id !== id);
};
select = (id: T["id"]): void => {
this.selectedId = id;
};
setItems = (items: T[]): void => {
this.items = items;
};
}
// Usage with specific types
interface Product extends Identifiable {
id: number;
name: string;
price: number;
category: string;
}
const productStore = new CrudStore();
// TypeScript now enforces that productStore.add() receives a Product
productStore.add({
id: 1,
name: "Widget",
price: 19.99,
category: "electronics",
});
// This would be a type error:
// productStore.add({ id: 1, name: "Widget" }); // missing price and category
The generic constraint T extends Identifiable ensures every entity has an id field. T["id"] extracts the exact id type from the generic, so if Product uses number ids, select and remove accept only number arguments.
Type-safe Actions with Generics and Overloads
When actions need to accept multiple shapes of payload, TypeScript overloads provide type safety without sacrificing flexibility:
class NotificationStore {
notifications: Array<
| { type: "info"; message: string; timestamp: Date }
| { type: "error"; message: string; code: number; timestamp: Date }
| { type: "success"; message: string; duration: number; timestamp: Date }
> = [];
constructor() {
makeAutoObservable(this);
}
// Overloaded action signatures
addNotification(type: "info", message: string): void;
addNotification(type: "error", message: string, code: number): void;
addNotification(type: "success", message: string, duration: number): void;
addNotification(
type: "info" | "error" | "success",
message: string,
extra?: number
): void {
const base = { message, timestamp: new Date() };
switch (type) {
case "info":
this.notifications.push({ type, ...base });
break;
case "error":
this.notifications.push({ type, code: extra!, ...base });
break;
case "success":
this.notifications.push({ type, duration: extra!, ...base });
break;
}
}
get latestError(): { message: string; code: number } | null {
const errors = this.notifications.filter(
(n): n is Extract => n.type === "error"
);
return errors.length > 0 ? errors[errors.length - 1] : null;
}
}
The overload signatures tell TypeScript exactly which combinations of arguments are valid. Calling addNotification("error", "Something broke") without a code number is now a compile error. The computed latestError uses a type predicate with Extract to narrow the discriminated union correctly.
Union Types for State Machines
MobX excels at representing finite state machines. TypeScript's discriminated unions make these state machines bulletproof:
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; errorMessage: string };
class AsyncDataStore {
state: FetchState = { status: "idle" };
constructor() {
makeAutoObservable(this);
}
get isIdle(): boolean {
return this.state.status === "idle";
}
get isLoading(): boolean {
return this.state.status === "loading";
}
get data(): T | null {
return this.state.status === "success" ? this.state.data : null;
}
get error(): string | null {
return this.state.status === "error" ? this.state.errorMessage : null;
}
fetch = async (fetcher: () => Promise): Promise => {
this.state = { status: "loading" };
try {
const data = await fetcher();
this.state = { status: "success", data };
} catch (e) {
this.state = {
status: "error",
errorMessage: e instanceof Error ? e.message : "Unknown error",
};
}
};
reset = (): void => {
this.state = { status: "idle" };
};
}
TypeScript narrows the FetchState union automatically. Inside the data getter, the check this.state.status === "success" tells TypeScript that this.state.data exists and is of type T. Without the check, accessing data would be a compile error—preventing the common bug of reading data while still loading.
Best Practices for MobX TypeScript
- Define interfaces for your state shape before implementing stores. This separates the contract from the implementation and makes testing easier.
- Use
makeAutoObservableas the default for simple stores. ReservemakeObservablefor cases where you need to exclude certain fields from observation or treat them specially. - Annotate computed getter return types explicitly when the derivation is complex. It serves as documentation and a safeguard against unintended type drift.
- Leverage discriminated unions for async state (idle/loading/success/error) instead of separate boolean flags. The compiler will enforce correct state access patterns.
- Keep actions pure in signature—they should accept data and modify state, not perform side effects like network calls directly. Delegate side effects to dedicated service layers and call actions with the results.
- Use generics for reusable store patterns (CRUD, pagination, async wrappers). Write them once, use them everywhere with full type safety.
- Avoid
anyand type assertions inside MobX stores. If TypeScript is complaining, it's usually because your state shape is ambiguous—refine your types instead of silencing the compiler. - Enable
strictin tsconfig. The combination ofstrictNullChecks,noImplicitAny, andstrictPropertyInitializationcatches uninitialized observables and null-reference bugs at compile time. - Use the
observerwrapper consistently on every component that reads observable state. Missingobserveris a common pitfall—TypeScript can't detect this, but establishing a lint rule helps. - Test your stores with strongly typed test utilities. Write unit tests that exercise actions and assert on computed values—TypeScript ensures your test assertions reference real properties.
Conclusion
Combining MobX with TypeScript gives you the best of both worlds: the intuitive, automatic reactivity of MobX and the rigorous compile-time safety of a powerful type system. By typing your observable state, actions, computed values, and reactions, you create a self-documenting codebase where the compiler catches mistakes before they reach your users. The patterns covered in this tutorial—from basic store typing to generic CRUD stores, discriminated union state machines, and typed React bindings—form a complete toolkit for building strongly typed MobX applications that scale confidently from prototypes to production systems. Start by adding interfaces to your existing MobX stores, enable strict mode, and let TypeScript guide you toward cleaner, safer state management.