Structuring Stores
Prefer computed values over manual sync
The most important pattern in FobX: put derived state in computeds, not in effects. Computeds are automatically cached, lazy, and act as firewalls:
// ❌ Don't compute derived state in a reaction
const store = observable({
items: [] as Item[],
totalPrice: 0,
updateTotal() {
this.totalPrice = this.items.reduce((sum, i) => sum + i.price, 0)
},
})
autorun(() => store.updateTotal()) // wasteful manual sync
// ✅ Use computed — automatically cached and lazy
const store = observable({
items: [] as Item[],
get totalPrice() {
return this.items.reduce((sum, i) => sum + i.price, 0)
},
})
Single store vs multiple stores
FobX does not prescribe a single store architecture. Choose based on your app’s complexity:
Single root store
Good for small-to-medium apps where all state is related:
import { observable } from "@fobx/core"
const store = observable({
user: null as User | null,
todos: [] as Todo[],
filter: "all" as "all" | "active" | "done",
get visibleTodos() {
if (this.filter === "all") return this.todos
const done = this.filter === "done"
return this.todos.filter((t) => t.done === done)
},
addTodo(text: string) {
this.todos.push({ text, done: false })
},
})
Multiple domain stores
Better for larger apps with distinct domains:
class AuthStore {
user: User | null = null
token: string | null = null
constructor() {
makeObservable(this, {
annotations: {
user: "observable",
token: "observable",
login: "flow",
},
})
}
*login(credentials: Credentials) {
const res = yield fetch("/api/login", {
method: "POST",
body: JSON.stringify(credentials),
})
const data = yield res.json()
this.user = data.user
this.token = data.token
}
}
class TodoStore {
todos: Todo[] = []
constructor(private auth: AuthStore) {
makeObservable(this, {
annotations: {
todos: "observable",
fetchTodos: "flow",
},
})
}
*fetchTodos() {
const res = yield fetch("/api/todos", {
headers: { Authorization: `Bearer ${this.auth.token}` },
})
this.todos = yield res.json()
}
}
// Wire stores together
const auth = new AuthStore()
const todos = new TodoStore(auth)
Plain objects vs classes
Plain objects with observable()
Best for simple, self-contained stores without lifecycle:
const counterStore = observable({
count: 0,
increment() {
this.count++
},
decrement() {
this.count--
},
reset() {
this.count = 0
},
})
Classes with makeObservable()
Best when you need:
- Prototype methods shared across instances
- Inheritance
- Constructor injection (dependency injection)
- TypeScript access modifiers (
private,protected)
class TimerStore {
elapsed = 0
private intervalId: number | null = null
constructor() {
makeObservable(this, {
annotations: {
elapsed: "observable",
start: "transaction",
stop: "transaction",
tick: "transaction",
},
})
}
private tick() {
this.elapsed++
}
start() {
if (this.intervalId) return
this.intervalId = setInterval(() => this.tick(), 1000)
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
}
}
Keeping stores framework-agnostic
A well-structured store has no framework imports. All React/DOM-specific code lives in the component layer:
// ✅ store.ts — pure FobX, no React
import { flow, makeObservable } from "@fobx/core"
class UserStore {
users: User[] = []
loading = false
constructor() {
makeObservable(this, {
annotations: {
users: "observable",
loading: "observable",
fetchUsers: "flow",
},
})
}
*fetchUsers() {
this.loading = true
const res = yield fetch("/api/users")
this.users = yield res.json()
this.loading = false
}
}
// ✅ UserList.tsx — React component consumes the store
import { observer } from "@fobx/react"
const UserList = observer(({ store }: { store: UserStore }) => (
<div>
{store.loading
? <p>Loading...</p>
: store.users.map((u) => <p key={u.id}>{u.name}</p>)}
</div>
))