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-domObservable 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 withobservable.set(initial)inbeforeEach - 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 - useSelector →
renderHook, wrap state mutations inact(), assert updated values
The key discipline: always reset module-level observable state in beforeEach to prevent test contamination.