A Remarkably Effective Pattern for State Management in React with Valtio
Valtio is my favourite option for fairly complex state in a single screen or a medium-sized React app. It gives you fine-grained reactivity with very little boilerplate.
You can also subscribe to changes outside React and test the state logic separately from components. I've found this useful when working with coding agents, as changes are easy to make and check.
Here is my pattern for using Valtio in 2026:
- A main store for app state.
- A second store for transient UI state.
- A history store that records changes to the main store, for undo, redo and displaying previous states.
I've been using this in Isometrically, Vectorable and Geometric Patterns.
Let's look at the how and why of each of these in turn.
1. Main Store
First, declare a type for the app state:
export type AppState = {
shapes: IsometricShape[]
}
Set up a store (proxy):
export const appState = proxy<AppState>({
shapes: [] as IsometricShape[],
})
We use it from the app by setting up a hook:
export function useAppState() {
return useSnapshot(appState) as AppState
}
The as cast lets components use the existing AppState type without dealing with readonly types. It is a convenience, but removes the type-level protection: the snapshot must still be treated as read-only.
To use within a component we simply call the hook:
const { shapes } = useAppState()
Valtio tracks which parts of the snapshot the component reads, and updates the component when those values change.
To modify the store, we write a function for each operation. These can live alongside the store and be imported directly into components. They are typed and easy to test:
export function addShape(shape: IsometricShape) {
appState.shapes.push(shape)
}
2. Transient UI State
The second store holds details such as whether a modal is open or which item is selected. The setup is the same, but we keep this state separate so undoing an edit needn't also undo a UI interaction.
If undo should affect a value, put it in app state. Otherwise, put it in transient UI state.
Again, we create a proxy, hooks and functions. A component imports what it needs without passing props through intermediate components. This is particularly handy when a button and the modal it opens live in different parts of the app.
3. History
History needs a little more thought.
Valtio's history proxy moved to a separate package. I've had issues with canUndo and canRedo failing to update components in React 19, so I use my own history store.
We also need to decide which changes belong together. Dragging a slider can produce dozens of intermediate values, but usually should create one undo step. The details depend on the app; here is the basic store:
type HistoryStore = {
history: AppState[]
currentIndex: number
}
export const historyStore = proxy<HistoryStore>({
history: [],
currentIndex: -1,
})
We can subscribe to changes outside React:
subscribe(appState, () => {
pushHistory(appState)
})
Then implement the history update:
export function pushHistory(state: AppState) {
const snapshotState = snapshot(state) as AppState
// Skip if state matches the current history entry (e.g. from undo/redo)
const currentState = historyStore.history[historyStore.currentIndex]
if (currentState && isEqual(snapshotState, currentState)) {
return
}
if (historyStore.history.length === 0) {
historyStore.history.push(snapshotState)
historyStore.currentIndex = 0
} else {
// Truncate any redo history when new change is made
if (historyStore.currentIndex < historyStore.history.length - 1) {
historyStore.history = historyStore.history.slice(
0,
historyStore.currentIndex + 1,
)
}
historyStore.history.push(snapshotState)
historyStore.currentIndex++
}
}
Now consider how undo might work. As it is Valtio we write a simple function that modifies the proxy:
export function undo() {
if (!canUndo()) return
historyStore.currentIndex = historyStore.currentIndex - 1
const targetState = historyStore.history[historyStore.currentIndex]
const restored = cloneDeep(targetState)
appState.selectedIds = restored.selectedIds
appState.shapes = restored.shapes
}
The cloneDeep and snapshot calls keep history entries separate from the live proxy. Otherwise, changing the current state could also change a saved state.
A hook makes this straightforward to use in components:
export function useHistoryState() {
const snap = useSnapshot(historyStore)
return {
canUndo: snap.currentIndex > 0,
canRedo: snap.currentIndex < snap.history.length - 1,
currentIndex: snap.currentIndex,
historyLength: snap.history.length,
}
}
The undo and redo functions can also be called from keyboard shortcuts.
Batching
For batching, track which item each update affects. If it matches the previous update, replace that history entry. A slider dragged through 100 values can then produce one history entry.
Change history: AppState[] to history: { state: AppState, target: string }[], then set and compare the targets in pushHistory.
You can also display these saved states in the UI. This is particularly useful in a graphics app, where a history preview can make it easier to find an earlier version.
Summary
This gives us three small stores with separate jobs. Valtio handles subscriptions, while ordinary functions handle edits and history. The part to think through for each app is which changes should form a single undo step.