skip to content

A React todo row shows a stale title after a MobX store updates todo.title; what do you check, and why must the component that renders the value be an observer?

level: seniorimportance: must knowfreq 45%

answer

  1. the most common forgotten wrapper
  2. who performed the read
  3. primitives passed down too early
  4. someone else's render callback
  5. is the field really observable

basics

~20 s

MobX tracks only reads made during an observer's own render. Check that the row is an observer, reads todo.title itself rather than receiving a pre-read string, is not rendered through a non-observer child or callback, and that title is observable.

solid answer

~40 s

`observer` from `mobx-react-lite` runs a component's render inside a MobX reaction: every observable **read during that render** becomes a subscription, and a later change re-renders exactly that component. Nothing else is tracked. So I check, in order: is the component wrapped in `observer` at all (the most common mistake); does it **dereference late**, reading `todo.title` itself, or does a non-observer parent read it and pass a string down; is the observable handed to a non-observer child, such as a third-party grid, or read inside a render callback that runs in another component's render (fix with `<Observer>`); and is `title` really observable — annotated, initialised before `makeAutoObservable`, and not hidden behind `observable.ref`. `isObservableProp(todo, 'title')` confirms the last point, and `configure({ observableRequiresReaction: true })` warns about reads outside any reactive context.

code

tsx · 23 lines
tsx
import type { ReactNode } from 'react'
import { observer, Observer } from 'mobx-react-lite'
import type { Todo } from './todo-store'

// Third-party, not an observer: give it plain values
function GridRow(props: { data: { title: string; done: boolean } }) {
  return <tr><td>{props.data.title}</td><td>{String(props.data.done)}</td></tr>
}

export const TodoGridRow = observer(({ todo }: { todo: Todo }) => (
  // reads happen here, inside the observer's render
  <GridRow data={{ title: todo.title, done: todo.done }} />
))

// Not an observer: it calls onRender during its own render
function Grid(props: { onRender: () => ReactNode }) {
  return <tr>{props.onRender()}</tr>
}

export const TodoCallbackRow = observer(({ todo }: { todo: Todo }) => (
  // the callback runs in Grid's render, so wrap its output in <Observer>
  <Grid onRender={() => <Observer>{() => <td>{todo.title}</td>}</Observer>} />
))

go deeper

for a junior

Remember the rule: wrap every component that reads MobX state in observer, and read the property inside that component.

for a middle

Explain that observer runs render in a reaction and subscribes to what that render read, so reads in a parent, callback or effect do not count.

for a senior

Diagnose stale UI systematically: missing observer, early dereferencing, non-observer children, render callbacks, unobservable fields, and confirm with isObservableProp and observableRequiresReaction.

for a principal

Weigh implicit subscriptions against explicit selectors for a team: set lint and configure options so tracking mistakes are caught in development rather than in review.

## How an observer component subscribes MobX does not use selectors or dependency arrays. `observer` from `mobx-react-lite` wraps a function component so that its render runs inside a MobX **reaction**. While that render executes, MobX records every observable property, array, map or computed value that is **read**. When the render finishes, the component is subscribed to exactly those values. When any of them changes, MobX schedules a re-render of that component, and the next render records a fresh set of reads. Three consequences follow, and every "it does not update" bug is one of them: 1. Only components wrapped in `observer` are subscribed; `observer` does **not** extend to children. 2. Only reads that happen **during that component's own synchronous render** count. Reads in event handlers, `useEffect`, timers or after an `await` are not tracked for rendering. 3. Only values that are actually **observable** can be tracked. ## The reserved scenario: a todo row that never updates ```tsx import { observer } from 'mobx-react-lite' // Parent is not an observer and reads the title itself function TodoList({ store }: { store: TodoStore }) { return store.todos.map((t) => <TodoRow key={t.id} title={t.title} />) } const TodoRow = observer(({ title }: { title: string }) => <li>{title}</li>) ``` `TodoRow` is an observer, but it reads nothing observable: it receives a plain string. The read `t.title` happens in `TodoList`, which is not an observer, so no reaction records it. Renaming the todo changes the store and nothing re-renders. The fix is to **dereference late** — pass the todo object and read the property where it is rendered: ```tsx const TodoList = observer(({ store }: { store: TodoStore }) => ( <ul>{store.todos.map((t) => <TodoRow key={t.id} todo={t} />)}</ul> )) const TodoRow = observer(({ todo }: { todo: Todo }) => <li>{todo.title}</li>) ``` Now a title change re-renders only that one `TodoRow`, and adding a todo re-renders the list. The MobX docs point out that this is the opposite of the habit in selector-based libraries, where you extract primitives early to help memoisation. ## The checklist, in order | Symptom | Cause | Fix | |---|---|---| | Nothing ever updates | component not wrapped in `observer` | wrap every component that reads observables | | Child gets a string or number prop | value read in a non-observer parent | pass the object; read the field in an observer | | Third-party component shows stale data | observable passed to a non-observer | pass plain values or `toJS(todo)`, or wrap the child | | Render-prop content is stale | read happens during another component's render | wrap the callback's output in `<Observer>` | | Only this one field is stale | field not observable | annotate it, initialise before `makeAutoObservable`, avoid `observable.ref` if you mutate inside | | A side effect ignores changes | value read in `useEffect` or a handler | read it during render, or use a reaction for side effects | ## Confirming the diagnosis - `isObservableProp(todo, 'title')` returns whether the property is observable at all. - `configure({ observableRequiresReaction: true })` makes MobX warn on every observable read that happens outside a reactive context, which surfaces missing `observer` wrappers quickly in development. - `trace()` placed inside an observer's render logs which observable change caused each re-render, so a change that never shows up there was never tracked. ## Why this design, and its cost Tracking at read time is what gives MobX its precision: the row re-renders on its own title, the counter on its own computed, without anyone writing selectors. `observer` also applies `React.memo`, so parents re-rendering with the same object props do not re-render observer children. The cost is that subscriptions are **implicit**: a refactor that moves a read from one component into another, or into a callback, silently changes what is tracked. The practical rule from the docs is simple — apply `observer` to every component that reads observable data; more observers make updates more fine-grained, not slower.

  • Why does an observer parent re-rendering not re-render its observer children with the same todo prop?
    `observer` applies `React.memo`, so a child whose props are the same object references skips re-rendering. That is safe in MobX because the child is subscribed to the fields it read; if `todo.title` changes, the child re-renders through its own subscription, not through its parent.
  • A MobX observer reads todo.title only inside a useEffect to set document.title. Why does it not follow changes?
    The effect runs after render, outside the observer's tracked render, so the read creates no subscription. Either read `todo.title` during render and list it in the effect's dependencies, or set up a MobX `reaction` or `autorun` inside the effect and return its disposer as the cleanup.

saying these in an interview costs you the question

  • Wrapping the top-level App in observer makes every child component reactive.
  • Passing todo.title down as a string is the recommended MobX pattern for memoisation.
  • MobX tracks reads anywhere in the component, including event handlers and effects.
  • observer components must also be wrapped in React.memo to avoid extra renders.
  • Fields added to a store after makeAutoObservable runs are tracked automatically.