Skip to main content

Overview

The observe() function is the core mechanism for reacting to changes in observables. It automatically tracks which observables are accessed and re-runs when any of them change.

Basic Usage

Simple Observer

Create a reactive observer that runs when dependencies change:
import { observable, observe } from '@legendapp/state'

const count$ = observable(0)

observe(() => {
  console.log('Count is:', count$.get())
})
// Logs: "Count is: 0"

count$.set(1)
// Logs: "Count is: 1"

count$.set(2)
// Logs: "Count is: 2"

Selector and Reaction

Separate the tracked selector from the reaction:
const user$ = observable({
  name: 'Alice',
  age: 30
})

// Selector: what to track
// Reaction: what to do when it changes
observe(
  () => user$.name.get(),
  (e) => {
    console.log('Name changed to:', e.value)
    console.log('Previous name:', e.previous)
  }
)

user$.name.set('Bob')
// Logs: "Name changed to: Bob"
// Logs: "Previous name: Alice"

Type Signatures

// Single function pattern
function observe<T>(
  run: (e: ObserveEvent<T>) => T | void,
  options?: ObserveOptions
): () => void

// Selector + reaction pattern  
function observe<T>(
  selector: Selector<T> | (() => T),
  reaction?: (e: ObserveEventCallback<T>) => any,
  options?: ObserveOptions
): () => void
interface ObserveEventCallback<T> {
  value: T              // Current value
  previous: T           // Previous value
  num: number           // Number of times triggered
  nodes: Map<NodeInfo, TrackingNode> // Tracked nodes
  refresh: () => void   // Manually refresh
}

interface ObserveOptions {
  immediate?: boolean   // Run immediately on changes (skip batching)
}

onChange() Method

Every observable has an onChange() method for listening to its changes:
const count$ = observable(0)

const dispose = count$.onChange((params) => {
  console.log('New value:', params.value)
  console.log('Previous:', params.getPrevious())
  console.log('Changes:', params.changes)
})

count$.set(1)
// Logs: "New value: 1"

// Stop listening
dispose()

onChange Parameters

interface ListenerParams<T> {
  value: T                    // Current value
  getPrevious: () => T        // Function to get previous value
  changes: Change[]           // Array of changes
  isFromSync: boolean         // From sync operation
  isFromPersist: boolean      // From persistence load
}

interface Change {
  path: string[]              // Path to changed value
  pathTypes: TypeAtPath[]     // Types along the path
  valueAtPath: any            // New value at path
  prevAtPath: any             // Previous value at path  
}

Tracking Options

Shallow Tracking

Only track direct changes, not nested properties:
const state$ = observable({
  user: {
    name: 'Alice',
    age: 30
  }
})

state$.user.onChange((params) => {
  console.log('User changed!')
}, { trackingType: true }) // true = shallow

state$.user.name.set('Bob')  // Does NOT trigger
state$.user.set({ name: 'Charlie', age: 35 }) // Triggers

Immediate Execution

Run the listener immediately, bypassing batching:
const count$ = observable(0)

count$.onChange(() => {
  console.log('Immediate:', count$.get())
}, { immediate: true })

batch(() => {
  count$.set(1)
  count$.set(2)
  count$.set(3)
})
// Logs three times immediately:
// "Immediate: 1"
// "Immediate: 2" 
// "Immediate: 3"

Run on Initial Value

Run the listener once with the current value:
const count$ = observable(5)

count$.onChange((params) => {
  console.log('Count:', params.value)
}, { initial: true })
// Immediately logs: "Count: 5"

Advanced Patterns

Multiple Dependencies

observe() automatically tracks all accessed observables:
const firstName$ = observable('Alice')
const lastName$ = observable('Smith')
const age$ = observable(30)

observe(() => {
  const first = firstName$.get()
  const last = lastName$.get()
  const age = age$.get()
  console.log(`${first} ${last}, age ${age}`)
})
// Logs: "Alice Smith, age 30"

firstName$.set('Bob')
// Logs: "Bob Smith, age 30"

lastName$.set('Jones')
// Logs: "Bob Jones, age 30"

age$.set(31)
// Logs: "Bob Jones, age 31"

Conditional Tracking

Track different observables based on conditions:
const mode$ = observable<'edit' | 'view'>('view')
const editData$ = observable({ text: 'Editing...' })
const viewData$ = observable({ text: 'Viewing...' })

observe(() => {
  const mode = mode$.get()
  
  if (mode === 'edit') {
    console.log('Edit:', editData$.get())
  } else {
    console.log('View:', viewData$.get())
  }
})

// Only tracks mode$ and viewData$
viewData$.set({ text: 'Updated view' }) // Triggers
editData$.set({ text: 'Updated edit' })  // Does NOT trigger

// Switch mode
mode$.set('edit')
// Now tracks mode$ and editData$
editData$.set({ text: 'New edit' }) // Triggers
viewData$.set({ text: 'New view' })  // Does NOT trigger

Cleanup Functions

Use cleanup functions for side effects:
observe((e) => {
  const id = userId$.get()
  
  // Start subscription
  const subscription = subscribeToUser(id)
  
  // Cleanup when dependencies change or observer is disposed
  e.onCleanup = () => {
    subscription.unsubscribe()
  }
})

Manual Refresh

Manually re-run an observer:
observe(
  () => data$.get(),
  (e) => {
    console.log('Data:', e.value)
    
    // Manually refresh after some time
    setTimeout(() => {
      e.refresh()
    }, 1000)
  }
)

Listening to Deep Changes

Nested Object Changes

const state$ = observable({
  user: {
    profile: {
      name: 'Alice',
      email: '[email protected]'
    }
  }
})

// Listen at the root
state$.onChange((params) => {
  console.log('Something changed!')
  console.log('Change path:', params.changes[0].path)
})

state$.user.profile.name.set('Bob')
// Logs: "Something changed!"
// Logs: "Change path: ['user', 'profile', 'name']"

Array Changes

const todos$ = observable([
  { id: 1, text: 'Buy milk', done: false },
  { id: 2, text: 'Walk dog', done: true }
])

todos$.onChange((params) => {
  console.log('Todos changed!')
  console.log('New todos:', params.value)
})

todos$.push({ id: 3, text: 'Write docs', done: false })
// Triggers with full new array

todos$[0].done.toggle()
// Also triggers - array item changed

Disposing Observers

All observe functions return a dispose function:
const dispose = observe(() => {
  console.log('Count:', count$.get())
})

// Later, stop observing
dispose()

// Changes no longer trigger the observer
count$.set(100) // Nothing happens

Performance Tips

Use peek() for Non-Dependencies

const userId$ = observable(1)
const config$ = observable({ apiUrl: '/api' })

observe(() => {
  const id = userId$.get()        // Tracked
  const url = config$.peek()      // Not tracked
  
  fetchUser(url, id)
})

// Triggers re-run
userId$.set(2)

// Does NOT trigger re-run
config$.set({ apiUrl: '/api/v2' })

Shallow Tracking for Performance

Avoid unnecessary re-runs with shallow tracking:
const items$ = observable([
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' }
])

// Only re-run when array length changes, not item properties
items$.onChange(() => {
  console.log('Array length:', items$.length)
}, { trackingType: true })

items$[0].name.set('Updated') // Does NOT trigger
items$.push({ id: 3, name: 'Item 3' }) // Triggers
Group related changes to trigger only one observer run:
import { batch } from '@legendapp/state'

const state$ = observable({
  count: 0,
  total: 0,
  average: 0
})

state$.onChange(() => {
  console.log('State updated!')
})

batch(() => {
  state$.count.set(10)
  state$.total.set(100)
  state$.average.set(10)
})
// Only logs "State updated!" once

Best Practices

Dispose Observers

Always dispose observers when they’re no longer needed to prevent memory leaks.

Use Shallow Tracking

For large objects, shallow tracking can significantly improve performance.

Cleanup Side Effects

Use onCleanup to properly clean up subscriptions, timers, and other resources.

Avoid Infinite Loops

Don’t modify the same observable you’re tracking inside an observer without guards.

Common Pitfalls

Modifying Tracked Observables

const count$ = observable(0)

// ❌ Bad: Creates infinite loop
observe(() => {
  const value = count$.get()
  count$.set(value + 1) // Never do this!
})

// ✅ Good: Use a separate observable or add guards
const trigger$ = observable(0)
const result$ = observable(0)

observe(() => {
  const value = trigger$.get()
  result$.set(value + 1) // Safe
})

Accessing Without get()

const user$ = observable({ name: 'Alice' })

// ❌ Bad: Doesn't track changes
observe(() => {
  console.log(user$) // Just logs the observable
})

// ✅ Good: Use get() to track
observe(() => {
  console.log(user$.get()) // Tracks changes
})

Next Steps

Computed Observables

Create derived values that update automatically

Batching

Optimize updates with batching

Build docs developers (and LLMs) love