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-domBasic 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.