Skip to main content

Overview

Batching allows you to group multiple observable updates together so that listeners and observers only run once after all changes complete. This is essential for performance when making multiple related updates.

The batch() Function

Wrap multiple updates in a batch() call:
import { observable, batch } from '@legendapp/state'

const state$ = observable({
  firstName: 'Alice',
  lastName: 'Smith',
  age: 30
})

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

// Without batching - logs 3 times
state$.firstName.set('Bob')
state$.lastName.set('Jones')
state$.age.set(31)
// Logs:
// "State changed!"
// "State changed!"
// "State changed!"

// With batching - logs once
batch(() => {
  state$.firstName.set('Charlie')
  state$.lastName.set('Brown')
  state$.age.set(32)
})
// Logs: "State changed!" (only once)

Type Signature

function batch(fn: () => void): void
function beginBatch(): void
function endBatch(): void

Manual Batching

For more control, use beginBatch() and endBatch():
import { beginBatch, endBatch } from '@legendapp/state'

const state$ = observable({
  items: [],
  total: 0,
  average: 0
})

beginBatch()

try {
  state$.items.set([1, 2, 3, 4, 5])
  state$.total.set(15)
  state$.average.set(3)
} finally {
  endBatch()
}
// All listeners notified together
Always call endBatch() in a finally block to ensure batching completes even if an error occurs.

How Batching Works

When you batch updates:
  1. Changes are collected: All set() calls within the batch are recorded
  2. Listeners are deferred: Listeners don’t run until the batch completes
  3. Notifications are consolidated: Each listener receives all changes at once
  4. Previous values are preserved: The getPrevious() function returns the value from before the entire batch
const count$ = observable(0)

count$.onChange((params) => {
  console.log('Current:', params.value)
  console.log('Previous:', params.getPrevious())
  console.log('Number of changes:', params.changes.length)
})

batch(() => {
  count$.set(1)
  count$.set(2)
  count$.set(3)
})
// Logs:
// "Current: 3"
// "Previous: 0" (from before the batch)
// "Number of changes: 1"

Nested Batching

Batches can be nested - notifications only fire when the outermost batch completes:
const state$ = observable({ a: 0, b: 0, c: 0 })

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

batch(() => {
  state$.a.set(1)
  
  batch(() => {
    state$.b.set(2)
    state$.c.set(3)
  })
  // Inner batch ends, but no notification yet
  
  state$.a.set(4)
})
// Outer batch ends - logs "Changed!" once

Automatic Batching

Some operations automatically batch updates:

Array Methods

Array modification methods are automatically batched:
const items$ = observable([1, 2, 3])

items$.onChange(() => {
  console.log('Items changed!')
})

// Automatically batched - logs once
items$.push(4, 5, 6)
// Logs: "Items changed!" (once)

// splice is also batched
items$.splice(0, 2, 10, 20)
// Logs: "Items changed!" (once)

assign() Method

The assign() method automatically batches property updates:
const user$ = observable({
  name: 'Alice',
  age: 30,
  email: '[email protected]'
})

user$.onChange(() => {
  console.log('User changed!')
})

// Automatically batched
user$.assign({
  age: 31,
  email: '[email protected]'
})
// Logs: "User changed!" (once)

Batching with Observers

Observers created with observe() also benefit from batching:
const firstName$ = observable('Alice')
const lastName$ = observable('Smith')

let runCount = 0
observe(() => {
  runCount++
  console.log(`${firstName$.get()} ${lastName$.get()}`)
})
// Logs: "Alice Smith"
// runCount: 1

// Without batching - runs twice
firstName$.set('Bob')
lastName$.set('Jones')
// Logs: "Bob Smith"
// Logs: "Bob Jones" 
// runCount: 3

// With batching - runs once
batch(() => {
  firstName$.set('Charlie')
  lastName$.set('Brown')
})
// Logs: "Charlie Brown" (once)
// runCount: 4

Performance Benefits

React Rendering

In React, batching prevents unnecessary re-renders:
const Component = () => {
  const state$ = useObservable({
    count: 0,
    total: 0
  })
  
  const increment = () => {
    // ❌ Without batching - component renders twice
    state$.count.set(c => c + 1)
    state$.total.set(t => t + 1)
  }
  
  const incrementBatched = () => {
    // ✅ With batching - component renders once
    batch(() => {
      state$.count.set(c => c + 1)
      state$.total.set(t => t + 1)
    })
  }
  
  return (
    <div>
      <div>Count: {state$.count.get()}</div>
      <div>Total: {state$.total.get()}</div>
      <button onClick={incrementBatched}>Increment</button>
    </div>
  )
}

Computed Updates

Batching prevents computed observables from recalculating multiple times:
const width$ = observable(100)
const height$ = observable(50)

let computeCount = 0
const area$ = computed(() => {
  computeCount++
  return width$.get() * height$.get()
})

area$.onChange(() => {
  console.log('Area:', area$.get())
})

// Without batching - computes twice
width$.set(200)
height$.set(100)
// computeCount: 2

// With batching - computes once
batch(() => {
  width$.set(300)
  height$.set(150)
})
// computeCount: 3 (only one more)

When to Use Batching

Multiple Related Updates

Always batch when updating multiple observables that should be treated as one logical change.

Form Submissions

Batch all field updates when submitting or resetting a form.

Bulk Operations

Batch when processing arrays or performing bulk updates to collections.

Initialization

Batch when setting up initial state with multiple properties.

Common Patterns

Form Updates

const form$ = observable({
  name: '',
  email: '',
  phone: '',
  address: ''
})

function updateForm(data: Partial<typeof form$>) {
  batch(() => {
    if (data.name !== undefined) form$.name.set(data.name)
    if (data.email !== undefined) form$.email.set(data.email)
    if (data.phone !== undefined) form$.phone.set(data.phone)
    if (data.address !== undefined) form$.address.set(data.address)
  })
}

// Or use assign which batches automatically
form$.assign(data)

Bulk Array Updates

const todos$ = observable([
  { id: 1, done: false },
  { id: 2, done: false },
  { id: 3, done: false }
])

function markAllDone() {
  batch(() => {
    todos$.forEach(todo => {
      todo.done.set(true)
    })
  })
}

State Synchronization

const localState$ = observable({
  items: [],
  lastSync: 0,
  isSyncing: false
})

async function syncFromServer() {
  const data = await fetchFromServer()
  
  batch(() => {
    localState$.items.set(data.items)
    localState$.lastSync.set(Date.now())
    localState$.isSyncing.set(false)
  })
}

Transaction-like Updates

const account$ = observable({
  balance: 1000,
  transactions: []
})

function transfer(amount: number, to: string) {
  batch(() => {
    account$.balance.set(b => b - amount)
    account$.transactions.push({
      amount,
      to,
      date: new Date(),
      type: 'transfer'
    })
  })
}

Batching with Promises

Batching works with async operations:
const state$ = observable({
  data: null,
  loading: false,
  error: null
})

async function fetchData() {
  // Set loading state
  batch(() => {
    state$.loading.set(true)
    state$.error.set(null)
  })
  
  try {
    const data = await fetch('/api/data').then(r => r.json())
    
    // Update with results
    batch(() => {
      state$.data.set(data)
      state$.loading.set(false)
    })
  } catch (error) {
    // Update with error
    batch(() => {
      state$.error.set(error)
      state$.loading.set(false)
    })
  }
}

Best Practices

Batch Related Changes

Group updates that represent a single logical operation.

Keep Batches Short

Don’t perform long-running operations inside batches - batch only the updates.

Use assign() When Possible

assign() automatically batches and is often clearer than manual batching.

Always Complete Batches

Use try/finally with manual batching to ensure endBatch() is called.

Common Mistakes

Long-Running Operations

// ❌ Bad - blocks notification
batch(() => {
  state$.data.set(newData)
  processData(newData) // Expensive operation
  state$.processed.set(true)
})

// ✅ Good - only batch the updates
const processed = processData(newData)
batch(() => {
  state$.data.set(newData)
  state$.processed.set(true)
})

Forgetting endBatch()

// ❌ Bad - endBatch might not be called
beginBatch()
state$.a.set(1)
if (condition) {
  return // endBatch never called!
}
endBatch()

// ✅ Good - always use try/finally
beginBatch()
try {
  state$.a.set(1)
  if (condition) {
    return
  }
} finally {
  endBatch()
}

// ✅ Even better - use batch()
batch(() => {
  state$.a.set(1)
  if (condition) {
    return
  }
})

Next Steps

React Integration

Learn how batching works with React components

Performance Tips

Discover more performance optimization techniques

Build docs developers (and LLMs) love