Component Patterns

Small observer components

Wrap small, focused components with observer. Each component tracks only the observables it reads, so smaller components re-render less:

// ❌ One large component — any tracked change re-renders everything it contains
const App = observer(() => (
  <div>
    <h1>{store.title}</h1>
    <ul>{store.items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>
    <p>Total: {store.total}</p>
  </div>
))

// ✅ Split into focused components
const Title = observer(() => <h1>{store.title}</h1>)
const ItemList = observer(() => (
  <ul>{store.items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>
))
const Total = observer(() => <p>Total: {store.total}</p>)

const App = () => (
  <div>
    <Title />
    <ItemList />
    <Total />
  </div>
)

Read late, not early

Observable reads must happen inside the observer render body. Avoid destructuring or reading observables before the component renders:

// ❌ Reading before the tracked render starts — not tracked
const name = store.name
const count = store.count
const BadComponent = observer(() => <p>{name}: {count}</p>)

// ✅ Reading inside the tracked render
const GoodComponent = observer(({ store }: { store: Store }) => (
  <p>{store.name}: {store.count}</p>
))

Destructuring inside the observer callback is fine. What matters is when the observable property access happens.

Callbacks and event handlers

Event handlers run outside the tracking context, so they can safely read and write observables. Use transactions for multi-property updates:

const Form = observer(() => {
  const handleSubmit = () => {
    // This runs outside render — mutations are fine
    store.submit()
  }

  return <button onClick={handleSubmit}>Submit</button>
})

Local state: useViewModel vs useState

Scenario Recommendation
Simple boolean/number/string useState (React built-in)
Complex state with derived values useViewModel
State with side effects (timers, subscriptions) useViewModel with onConnect/onDisconnect
State shared between components External store (observable / makeObservable)

onConnect() and onDisconnect() run via useEffect, so keep them idempotent under React 18 StrictMode development replay.

// Simple toggle — useState is fine
function Toggle() {
  const [open, setOpen] = useState(false)
  return (
    <button onClick={() => setOpen(!open)}>{open ? "Close" : "Open"}</button>
  )
}

// Complex form — useViewModel shines
class SearchVM extends ViewModel<{ onResults: (r: Result[]) => void }> {
  query = ""
  results: Result[] = []
  loading = false

  constructor(props: { onResults: (r: Result[]) => void }) {
    super(props)
    observable(this)
  }

  get hasResults() {
    return this.results.length > 0
  }

  *search() {
    this.loading = true
    const res = yield fetch(`/api/search?q=${encodeURIComponent(this.query)}`)
    this.results = yield res.json()
    this.loading = false
    this.props.onResults(this.results)
  }
}

Passing observables through context

You can pass stores via React context. Just make sure consuming components are wrapped in observer:

const StoreContext = React.createContext<AppStore>(null!)

const StoreProvider = ({ children }: { children: React.ReactNode }) => {
  const [store] = useState(() => new AppStore())
  return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
}

const Display = observer(() => {
  const store = useContext(StoreContext)
  return <p>{store.displayValue}</p>
})

Avoid unnecessary observer wrapping

Components that don’t read any observables don’t need observer:

// ✅ No observables read — no observer needed
function Layout({ children }: { children: React.ReactNode }) {
  return <div className="layout">{children}</div>
}

// ✅ Reads observables — needs observer
const Header = observer(() => <h1>{store.title}</h1>)