Testing Valtio Proxy State: subscribe, snapshot, Derived State, and React Integration

Testing Valtio Proxy State: subscribe, snapshot, Derived State, and React Integration

Valtio creates proxy state that you mutate directly. Its simplicity is also what makes testing it slightly unconventional — there's no dispatch, no reducers, and the proxy itself is mutable. The keys are using snapshot() for immutable assertions and subscribe() for testing reactive behaviour.

Setup

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

Basic Proxy State Testing

// state/cartState.ts
import { proxy, subscribe } from 'valtio'
import { derive } from 'valtio/utils'

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

export const cartState = proxy({
  items: [] as CartItem[],
})

export const cartDerived = derive({
  total: (get) =>
    get(cartState).items.reduce((sum, item) => sum + item.price * item.quantity, 0),
  itemCount: (get) =>
    get(cartState).items.reduce((sum, item) => sum + item.quantity, 0),
})

export function addItem(item: Omit<CartItem, 'quantity'>) {
  const existing = cartState.items.find((i) => i.id === item.id)
  if (existing) {
    existing.quantity++
  } else {
    cartState.items.push({ ...item, quantity: 1 })
  }
}

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

export function clearCart() {
  cartState.items = []
}

The key challenge with Valtio in tests: because it uses module-level proxy state, tests that run sequentially can share state. Reset state in beforeEach:

// state/cartState.test.ts
import { getVersion, snapshot } from 'valtio'
import { describe, it, expect, beforeEach } from 'vitest'
import { cartState, cartDerived, addItem, removeItem, clearCart } from './cartState'

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

beforeEach(() => {
  // Reset proxy state between tests
  cartState.items = []
})

describe('cartState', () => {
  it('starts empty', () => {
    const snap = snapshot(cartState)
    expect(snap.items).toHaveLength(0)
  })

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

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

  it('increments quantity when adding same item twice', () => {
    addItem(headphones)
    addItem(headphones)

    const snap = snapshot(cartState)
    expect(snap.items).toHaveLength(1)
    expect(snap.items[0].quantity).toBe(2)
  })

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

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

  it('clears all items', () => {
    addItem(headphones)
    addItem(hub)
    clearCart()

    expect(snapshot(cartState).items).toHaveLength(0)
  })
})

Using snapshot() for Assertions

snapshot() creates an immutable copy of the current proxy state. Use it for equality assertions:

describe('snapshot assertions', () => {
  it('snapshot is deeply equal to the expected state', () => {
    addItem(headphones)

    const snap = snapshot(cartState)
    expect(snap).toMatchObject({
      items: [
        { id: 'p1', name: 'Headphones', price: 79.99, quantity: 1 },
      ],
    })
  })

  it('snapshot does not mutate when proxy changes', () => {
    addItem(headphones)
    const snap1 = snapshot(cartState)

    addItem(hub)
    const snap2 = snapshot(cartState)

    // snap1 is immutable — still reflects state when taken
    expect(snap1.items).toHaveLength(1)
    expect(snap2.items).toHaveLength(2)
  })
})

Testing Derived State

Valtio's derive() computes values from proxy state. Test derived state by mutating the source proxy:

describe('cartDerived', () => {
  beforeEach(() => {
    cartState.items = []
  })

  it('total is 0 for empty cart', () => {
    expect(cartDerived.total).toBe(0)
  })

  it('total sums prices with quantities', () => {
    addItem(headphones) // 79.99 × 1
    addItem(headphones) // 79.99 × 2 = 159.98
    addItem(hub)        // 39.99 × 1

    expect(cartDerived.total).toBeCloseTo(199.97, 2)
  })

  it('itemCount sums all quantities', () => {
    addItem(headphones) // quantity 1
    addItem(headphones) // quantity 2
    addItem(hub)        // quantity 1

    expect(cartDerived.itemCount).toBe(3)
  })
})

Testing subscribe()

Subscribe notifies when proxy state changes:

import { subscribe } from 'valtio'
import { vi } from 'vitest'

describe('cartState subscriptions', () => {
  beforeEach(() => {
    cartState.items = []
  })

  it('notifies subscriber when items change', () => {
    const listener = vi.fn()
    const unsubscribe = subscribe(cartState, listener)

    addItem(headphones)

    expect(listener).toHaveBeenCalled()

    unsubscribe()
  })

  it('does not notify after unsubscribe', () => {
    const listener = vi.fn()
    const unsubscribe = subscribe(cartState, listener)
    unsubscribe()

    addItem(headphones)

    expect(listener).not.toHaveBeenCalled()
  })

  it('notifies once per batch of changes', async () => {
    const listener = vi.fn()
    const unsubscribe = subscribe(cartState, listener)

    // Valtio batches changes within the same tick
    addItem(headphones)
    addItem(hub)

    // Let microtasks settle
    await Promise.resolve()

    // May be 1 or 2 depending on Valtio batching — test the final state instead
    const snap = snapshot(cartState)
    expect(snap.items).toHaveLength(2)

    unsubscribe()
  })
})

Testing with proxyMap and proxySet

Valtio provides proxyMap and proxySet for reactive Map/Set collections:

// state/selectedItems.ts
import { proxySet } from 'valtio/utils'

export const selectedIds = proxySet<string>()
// state/selectedItems.test.ts
import { snapshot } from 'valtio'
import { describe, it, expect, beforeEach } from 'vitest'
import { selectedIds } from './selectedItems'

beforeEach(() => {
  selectedIds.clear()
})

describe('selectedIds proxySet', () => {
  it('starts empty', () => {
    expect(selectedIds.size).toBe(0)
  })

  it('adds an id', () => {
    selectedIds.add('p1')
    expect(selectedIds.has('p1')).toBe(true)
  })

  it('removes an id', () => {
    selectedIds.add('p1')
    selectedIds.delete('p1')
    expect(selectedIds.has('p1')).toBe(false)
  })

  it('snapshot converts to correct structure', () => {
    selectedIds.add('p1')
    selectedIds.add('p2')
    const snap = snapshot(selectedIds)
    // proxySet snapshot is iterable
    expect([...snap]).toEqual(expect.arrayContaining(['p1', 'p2']))
  })
})

React Component Integration

Valtio's useSnapshot hook re-renders components when proxy state changes:

// components/CartBadge.tsx
import { useSnapshot } from 'valtio'
import { cartState, cartDerived } from '@/state/cartState'

export function CartBadge() {
  const snap = useSnapshot(cartState)
  const derivedSnap = useSnapshot(cartDerived)

  if (snap.items.length === 0) return null

  return (
    <div className="cart-badge">
      <span>{derivedSnap.itemCount} items</span>
      <span>${derivedSnap.total.toFixed(2)}</span>
    </div>
  )
}
// components/CartBadge.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { cartState } from '@/state/cartState'
import { CartBadge } from './CartBadge'

beforeEach(() => {
  cartState.items = []
})

describe('CartBadge', () => {
  it('renders nothing for empty cart', () => {
    const { container } = render(<CartBadge />)
    expect(container).toBeEmptyDOMElement()
  })

  it('shows item count and total when cart has items', () => {
    cartState.items = [
      { id: 'p1', name: 'Headphones', price: 79.99, quantity: 2 },
    ]

    render(<CartBadge />)

    expect(screen.getByText('2 items')).toBeInTheDocument()
    expect(screen.getByText('$159.98')).toBeInTheDocument()
  })

  it('updates when proxy state changes after render', () => {
    render(<CartBadge />)

    // Initially empty — nothing rendered
    expect(screen.queryByText(/items/)).not.toBeInTheDocument()

    // Mutate the proxy directly
    cartState.items.push({ id: 'p1', name: 'A', price: 10, quantity: 1 })

    // Component re-renders via useSnapshot
    expect(screen.getByText('1 items')).toBeInTheDocument()
  })
})

Isolating Module-Level State

The biggest challenge with Valtio is module-level state. For complete isolation, use a factory pattern:

// state/createCartState.ts
import { proxy } from 'valtio'
import { derive } from 'valtio/utils'

export function createCartState() {
  const cart = proxy({ items: [] as CartItem[] })
  const derived = derive({
    total: (get) => get(cart).items.reduce((sum, i) => sum + i.price * i.quantity, 0),
  })

  return { cart, derived }
}

// For production use
export const { cart: cartState, derived: cartDerived } = createCartState()

In tests, create a fresh instance:

import { createCartState } from './createCartState'

describe('isolated cart tests', () => {
  it('each test gets a fresh cart', () => {
    const { cart, derived } = createCartState()

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

    expect(derived.total).toBe(10)
    // Another test's createCartState() would start fresh
  })
})

What Automated Tests Miss

Valtio proxy tests cover state logic, but not:

  • Re-render counts — Valtio optimizes renders, but excessive subscriptions may cause unexpected re-renders
  • Memory leaks from subscriptions — unsubscribed callbacks holding references
  • SSR hydration — proxy state initialized on server vs client
  • DevTools snapshots — Valtio's Redux DevTools integration

HelpMeTest monitors your live app and catches Valtio-driven UI regressions in real browser sessions.

Summary

Testing Valtio:

  • Direct mutation → mutate the proxy, assert with snapshot()
  • Isolation → reset module-level state in beforeEach; prefer factory functions for full isolation
  • subscribe() → attach listener, mutate, assert listener called, always unsubscribe
  • derive() → mutate source proxy, read derived value directly
  • React → mutate proxy before render, or after render to test reactivity
  • proxyMap/proxySet → test .has(), .size, and snapshot iteration

snapshot() is your assertion tool — never assert against the proxy object directly, as it's a live reference that can change under you.

Read more