Patterns and Anti-Patterns

This page focuses on the habits that tend to make FobX code work well over time, and the habits that usually create unnecessary work, subtle bugs, or reactivity that is harder to reason about.

The short version is:

  • use observables for source state
  • use computeds for derived state
  • use reactions for side effects
  • use transactions for grouped writes
  • keep the dependency graph simple and intentional

1. Prefer computeds over manually synchronized derived state

Good

const cart = observable({
  items: [] as { price: number; qty: number }[],
  get total() {
    return this.items.reduce((sum, item) => sum + item.price * item.qty, 0)
  },
})

Avoid

const cart = observable({
  items: [] as { price: number; qty: number }[],
  total: 0,
})

autorun(() => {
  cart.total = cart.items.reduce((sum, item) => sum + item.price * item.qty, 0)
})

Why:

  • the computed version is lazy and cached while observed
  • there is no extra synchronization path to maintain
  • downstream reactions are only notified when the computed output changes

Manual syncing through autorun() usually means you are using a side-effect API to maintain derived state that should just be expressed declaratively.

2. Use reactions for side effects, not state shaping

Good

const stop = reaction(
  () => auth.userId,
  (userId) => {
    localStorage.setItem("last-user-id", String(userId))
  },
)

Avoid

reaction(
  () => settings.theme,
  (theme) => {
    settings.themeLabel = theme.toUpperCase()
  },
)

If the target is more observable state, ask whether it should be a computed instead.

Reactions are the right tool when you need to:

  • talk to the network
  • touch storage
  • log
  • bridge to timers or external libraries
  • update a non-reactive host environment

They are usually the wrong tool for keeping your own state graph in sync.

Good

runInTransaction(() => {
  profile.firstName = "Ada"
  profile.lastName = "Lovelace"
})

Avoid

profile.firstName = "Ada"
profile.lastName = "Lovelace"

when those two writes are really one logical event.

Why:

  • reactions see one consistent final snapshot
  • you avoid extra reruns
  • grouped writes are easier to reason about during debugging

If the work is reusable, prefer transaction(fn) over repeatedly wrapping the same logic with runInTransaction().

4. Keep source state minimal

Good

const filters = observable({
  query: "",
  category: "all",
  get isFiltered() {
    return this.query !== "" || this.category !== "all"
  },
})

Avoid

const filters = observable({
  query: "",
  category: "all",
  isFiltered: false,
})

reaction(
  () => [filters.query, filters.category],
  ([query, category]) => {
    filters.isFiltered = query !== "" || category !== "all"
  },
)

The more duplicated state you store, the more invariants you have to maintain.

5. Let conditional tracking do the work

FobX only tracks the reads that actually happen.

Good

const activeTab = observableBox<"profile" | "billing">("profile")
const profileName = observableBox("Ada Lovelace")
const billingPlan = observableBox("Pro")

const heading = computed(() =>
  activeTab.get() === "profile"
    ? `Profile: ${profileName.get()}`
    : `Plan: ${billingPlan.get()}`
)

Avoid

const heading = computed(() => {
  const name = profileName.get()
  const plan = billingPlan.get()
  return activeTab.get() === "profile" ? `Profile: ${name}` : `Plan: ${plan}`
})

The second version tracks both values even though only one branch matters for each run.

6. Use observable.ref or shallow annotations intentionally

Good

class ViewState {
  schema: FormSchema

  constructor(schema: FormSchema) {
    this.schema = schema
    makeObservable(this, {
      annotations: {
        schema: "observable.ref",
      },
    })
  }
}

Avoid

Deep-observing large immutable objects, third-party instances, or external data graphs that you only ever replace wholesale.

Why:

  • deep observation is useful for mutable, domain-owned data
  • reference observation is better when replacement matters more than internals
  • shallow annotations are often a good fit for large collections of already well-structured values

7. Dispose long-lived reactions explicitly

Good

const stop = autorun(() => {
  console.log(store.count)
})

// later
stop()

Avoid

Creating reactions in long-lived services, DOM integrations, or tests without a clear disposal path.

Reactions are lightweight, but they are still subscriptions. If the owner goes away, the reaction should go away too.

8. Use runWithoutTracking() sparingly and intentionally

Good

autorun(() => {
  const tracked = search.query
  const snapshot = runWithoutTracking(() => debugState.get())
  console.log(tracked, snapshot)
})

Avoid

Wrapping large parts of your logic in runWithoutTracking() just to suppress reruns you do not fully understand.

If tracking is causing surprising updates, the first question should usually be:

  • am I reading too much inside this computation?
  • should some of this logic be split into a computed?
  • is a branch reading more observables than necessary?

runWithoutTracking() is a precision tool, not a general fix for noisy graphs.

9. Prefer framework-agnostic stores

Good

class UserStore {
  users: User[] = []
  loading = false

  constructor() {
    makeObservable(this, {
      annotations: {
        users: "observable",
        loading: "observable",
        fetchUsers: "flow",
      },
    })
  }

  *fetchUsers() {
    this.loading = true
    try {
      const res = yield fetch("/api/users")
      this.users = yield res.json()
    } finally {
      this.loading = false
    }
  }
}

Avoid

Mixing framework components, DOM manipulation, or transport-specific details directly into your store model unless the store is explicitly meant to be a host-specific adapter.

This keeps your reactive graph reusable across React, DOM, tests, and services.

10. Make async state explicit

Good

class DataStore {
  data: Item[] = []
  loading = false
  error: string | null = null

  constructor() {
    makeObservable(this, {
      annotations: {
        data: "observable",
        loading: "observable",
        error: "observable",
        fetchData: "flow",
      },
    })
  }

  *fetchData() {
    this.loading = true
    this.error = null
    try {
      const res = yield fetch("/api/items")
      this.data = yield res.json()
    } catch (error) {
      this.error = String(error)
    } finally {
      this.loading = false
    }
  }
}

Avoid

Encoding async lifecycle indirectly through nullable data alone, such as “no data means loading, unless it means idle, unless it means failure”.

Explicit loading and error state makes reactions and UI bindings much easier to understand.

11. Prefer plain reads inside computeds and reactions

Good

const visibleTodos = computed(() => {
  const filter = store.filter
  return filter === "all"
    ? store.todos
    : store.todos.filter((todo) => todo.done === (filter === "done"))
})

Avoid

Doing unrelated work, creating timers, mutating state, or triggering network effects inside a computed.

Computeds should stay pure and derivational. If it has side effects, it is probably a reaction instead.

Quick decision rules

When you are unsure which primitive to use:

  • if it is source state: observable
  • if it is derived from other state: computed
  • if it changes the outside world: reaction
  • if it groups writes: transaction
  • if it spans async steps with batched sync segments: flow

Those five rules cover most design decisions in FobX.