Testing Legend State: Observables, Computed, Persistence, and React Integration

Testing Legend State: Observables, Computed, Persistence, and React Integration

Legend State is a high-performance state library built on a signals-based observable system. It's designed to minimize re-renders and handle large datasets efficiently. Testing it requires understanding how to read and write observables in tests, how to test computed values, and how to verify persistence.

Setup

npm install @legendapp/state
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

Observable Basics in Tests

Legend State's observable() creates a state container. Use .get() to read, .set() to write, and .peek() to read without tracking:

// state/cartState.ts
import { observable, computed } from '@legendapp/state'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

export const cartState = observable({
  items: [] as CartItem[],
  couponCode: null as string | null,
  discountPercent: 0,
})

export const cartTotal = computed(() => {
  const items = cartState.items.get()
  const discount = cartState.discountPercent.get()
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  return subtotal * (1 - discount / 100)
})

export const itemCount = computed(() =>
  cartState.items.get().reduce((sum, item) => sum + item.quantity, 0)
)

export function addItem(item: Omit<CartItem, 'quantity'>) {
  const items = cartState.items.peek()
  const existing = items.find((i) => i.id === item.id)

  if (existing) {
    cartState.items[items.indexOf(existing)].quantity.set((q) => q + 1)
  } else {
    cartState.items.push({ ...item, quantity: 1 })
  }
}

export function removeItem(id: string) {
  cartState.items.set((items) => items.filter((i) => i.id !== id))
}

export function applyCoupon(code: string, discountPercent: number) {
  cartState.couponCode.set(code)
  cartState.discountPercent.set(discountPercent)
}
// state/cartState.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { cartState, cartTotal, itemCount, addItem, removeItem, applyCoupon } from './cartState'

const headphones = { id: 'p1', name: 'Headphones', price: 80 }
const hub = { id: 'p2', name: 'USB-C Hub', price: 40 }

beforeEach(() => {
  // Reset observable state between tests
  cartState.set({
    items: [],
    couponCode: null,
    discountPercent: 0,
  })
})

describe('cartState observables', () => {
  it('starts empty', () => {
    expect(cartState.items.get()).toHaveLength(0)
    expect(cartState.couponCode.get()).toBeNull()
    expect(cartState.discountPercent.get()).toBe(0)
  })

  it('adds a new item', () => {
    addItem(headphones)

    const items = cartState.items.get()
    expect(items).toHaveLength(1)
    expect(items[0].name).toBe('Headphones')
    expect(items[0].quantity).toBe(1)
  })

  it('increments quantity for duplicate item', () => {
    addItem(headphones)
    addItem(headphones)

    expect(cartState.items.get()).toHaveLength(1)
    expect(cartState.items[0].quantity.get()).toBe(2)
  })

  it('removes an item', () => {
    addItem(headphones)
    addItem(hub)
    removeItem('p1')

    const items = cartState.items.get()
    expect(items).toHaveLength(1)
    expect(items[0].id).toBe('p2')
  })
})

Testing Computed Values

Legend State's computed() lazily evaluates and caches results:

describe('cartTotal computed', () => {
  beforeEach(() => {
    cartState.set({ items: [], couponCode: null, discountPercent: 0 })
  })

  it('returns 0 for empty cart', () => {
    expect(cartTotal.get()).toBe(0)
  })

  it('sums prices with quantities', () => {
    cartState.items.set([
      { id: 'p1', name: 'A', price: 80, quantity: 2 },
      { id: 'p2', name: 'B', price: 40, quantity: 1 },
    ])

    expect(cartTotal.get()).toBe(200)
  })

  it('applies discount percentage', () => {
    cartState.items.set([{ id: 'p1', name: 'A', price: 100, quantity: 1 }])
    cartState.discountPercent.set(20)

    expect(cartTotal.get()).toBe(80)
  })

  it('updates when items change', () => {
    cartState.items.set([{ id: 'p1', name: 'A', price: 100, quantity: 1 }])
    expect(cartTotal.get()).toBe(100)

    cartState.items.push({ id: 'p2', name: 'B', price: 50, quantity: 1 })
    expect(cartTotal.get()).toBe(150)
  })

  it('applies coupon discount correctly', () => {
    addItem({ id: 'p1', name: 'A', price: 100 })
    applyCoupon('SAVE20', 20)

    expect(cartTotal.get()).toBe(80)
    expect(cartState.couponCode.get()).toBe('SAVE20')
  })
})

Testing observe() and onChange()

Legend State supports fine-grained subscriptions. Test them by setting up listeners and triggering mutations:

import { observe } from '@legendapp/state'
import { vi } from 'vitest'

describe('observable subscriptions', () => {
  beforeEach(() => {
    cartState.set({ items: [], couponCode: null, discountPercent: 0 })
  })

  it('observe() runs immediately and on change', () => {
    const handler = vi.fn()

    const disposer = observe(() => {
      handler(cartState.items.get().length)
    })

    // Runs immediately with initial value
    expect(handler).toHaveBeenCalledWith(0)

    addItem(headphones)

    // Runs again after change
    expect(handler).toHaveBeenCalledWith(1)

    disposer()
  })

  it('onChange() only fires on change, not immediately', () => {
    const handler = vi.fn()

    const disposer = cartState.items.onChange(handler)

    // Should NOT be called immediately
    expect(handler).not.toHaveBeenCalled()

    addItem(headphones)

    expect(handler).toHaveBeenCalledOnce()

    disposer()
  })

  it('does not call handler after disposal', () => {
    const handler = vi.fn()
    const disposer = cartState.items.onChange(handler)
    disposer()

    addItem(headphones)

    expect(handler).not.toHaveBeenCalled()
  })
})

Testing Persistence

Legend State has persistence plugins for localStorage, AsyncStorage, and custom backends:

// state/settingsState.ts
import { observable } from '@legendapp/state'
import { persistObservable } from '@legendapp/state/persist'
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'

export const settingsState = observable({
  theme: 'light' as 'light' | 'dark' | 'system',
  language: 'en',
})

persistObservable(settingsState, {
  local: 'settings',
  pluginLocal: ObservablePersistLocalStorage,
})

Test with a mocked localStorage:

// state/settingsState.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'

const localStorageMock = (() => {
  let store: Record<string, string> = {}
  return {
    getItem: vi.fn((key: string) => store[key] ?? null),
    setItem: vi.fn((key: string, value: string) => { store[key] = value }),
    removeItem: vi.fn((key: string) => { delete store[key] }),
    clear: vi.fn(() => { store = {} }),
  }
})()

Object.defineProperty(global, 'localStorage', { value: localStorageMock })

describe('settingsState persistence', () => {
  beforeEach(() => {
    localStorageMock.clear()
    vi.clearAllMocks()
  })

  it('persists to localStorage when state changes', async () => {
    const { settingsState } = await import('./settingsState')

    settingsState.theme.set('dark')

    expect(localStorageMock.setItem).toHaveBeenCalled()
    const stored = JSON.parse(localStorageMock.setItem.mock.calls[0][1])
    expect(stored.theme).toBe('dark')
  })
})

React Component Integration

Legend State provides Observer and useSelector for React integration:

// components/CartTotal.tsx
import { Observer } from '@legendapp/state/react'
import { cartTotal, itemCount } from '@/state/cartState'

export function CartTotal() {
  return (
    <Observer>
      {() => (
        <div>
          <p>{itemCount.get()} items</p>
          <p>Total: ${cartTotal.get().toFixed(2)}</p>
        </div>
      )}
    </Observer>
  )
}
// components/CartTotal.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { cartState } from '@/state/cartState'
import { CartTotal } from './CartTotal'

beforeEach(() => {
  cartState.set({ items: [], couponCode: null, discountPercent: 0 })
})

describe('CartTotal', () => {
  it('shows zero items and zero total for empty cart', () => {
    render(<CartTotal />)

    expect(screen.getByText('0 items')).toBeInTheDocument()
    expect(screen.getByText('Total: $0.00')).toBeInTheDocument()
  })

  it('shows correct totals when cart has items', () => {
    cartState.items.set([
      { id: 'p1', name: 'A', price: 80, quantity: 2 },
    ])

    render(<CartTotal />)

    expect(screen.getByText('2 items')).toBeInTheDocument()
    expect(screen.getByText('Total: $160.00')).toBeInTheDocument()
  })

  it('updates when cart changes after render', () => {
    render(<CartTotal />)

    expect(screen.getByText('0 items')).toBeInTheDocument()

    cartState.items.push({ id: 'p1', name: 'A', price: 50, quantity: 1 })

    expect(screen.getByText('1 items')).toBeInTheDocument()
    expect(screen.getByText('Total: $50.00')).toBeInTheDocument()
  })
})

Testing with useSelector

useSelector reads from observables in hooks and re-renders when values change:

// hooks/useCartSummary.ts
import { useSelector } from '@legendapp/state/react'
import { cartState, cartTotal } from '@/state/cartState'

export function useCartSummary() {
  const items = useSelector(cartState.items)
  const total = useSelector(cartTotal)

  return { items, total, isEmpty: items.length === 0 }
}
// hooks/useCartSummary.test.tsx
import { renderHook } from '@testing-library/react'
import { describe, it, expect, beforeEach, act } from 'vitest'
import { cartState } from '@/state/cartState'
import { useCartSummary } from './useCartSummary'

beforeEach(() => {
  cartState.set({ items: [], couponCode: null, discountPercent: 0 })
})

describe('useCartSummary', () => {
  it('returns empty state initially', () => {
    const { result } = renderHook(() => useCartSummary())

    expect(result.current.isEmpty).toBe(true)
    expect(result.current.total).toBe(0)
  })

  it('updates when cart state changes', () => {
    const { result } = renderHook(() => useCartSummary())

    act(() => {
      cartState.items.push({ id: 'p1', name: 'A', price: 100, quantity: 1 })
    })

    expect(result.current.isEmpty).toBe(false)
    expect(result.current.total).toBe(100)
  })
})

Performance Testing with Legend State

Legend State is designed for minimal re-renders. Verify this in tests:

import { render } from '@testing-library/react'
import { vi } from 'vitest'

describe('CartTotal re-render minimization', () => {
  it('does not re-render when unrelated state changes', () => {
    const renderCount = { value: 0 }

    function TrackedCartTotal() {
      renderCount.value++
      return <CartTotal />
    }

    cartState.items.set([{ id: 'p1', name: 'A', price: 80, quantity: 1 }])
    render(<TrackedCartTotal />)

    const initialRenderCount = renderCount.value

    // Change unrelated state (couponCode doesn't affect total if discount is 0)
    act(() => {
      cartState.couponCode.set('TEST')
    })

    // CartTotal should not re-render for couponCode change alone
    // (depends on your computed implementation)
    expect(renderCount.value).toBe(initialRenderCount)
  })
})

What Automated Tests Miss

Legend State unit tests cover observable logic but not:

  • React Suspense with async observables — async reads that suspend the component tree
  • Cross-device persistence sync — when state syncs across browser tabs or devices
  • Memory usage — whether observables and computed values are properly garbage collected
  • Batched updates — Legend State batches updates; test isolation from batching may hide bugs

HelpMeTest schedules continuous browser tests against your deployed app, catching Legend State-driven UI regressions that unit tests can't reach.

Summary

Testing Legend State:

  • Observables → read with .get(), write with .set() or .push(); reset with observable.set(initial) in beforeEach
  • Computed → mutate source observables, assert .get() returns derived value
  • observe() → fires immediately + on change; onChange() fires on change only; always dispose
  • Persistence → mock localStorage; assert .setItem() called with correct values
  • React → wrap components in Observer; mutate state directly in tests; assert rendered output
  • useSelectorrenderHook, wrap state mutations in act(), assert updated values

The key discipline: always reset module-level observable state in beforeEach to prevent test contamination.

Read more