A Remarkably Effective Pattern for State Management in React with Valtio
Valtio is my favourite solution to state management in React for a wide range of contexts. It is ideal for fairly complex FE state on a single screen or medium size 'App'. It does fine grained reactivity without tedious boilerplate. It, unlike many alternatives, can be subscribed to outside of React components. It is delightfully easy to test both outside of components and within. This makes it ideal for use with agentic coding, as it is easy to modify and verify, not that those weren't great qualities before!
Here is my pattern for using Valtio in 2026:
- Create a main store for major app state.
- Create a second store for transient UI state.
- Create a third store, that is populated on changes to the first one. This is the history store and can allow for flexible undo/redo along with showing history.
I've been using it for all of Isometrically, Vectorable and Geometric Patterns.
Let's look at the how and why of each of these in turn.
1. Main Store
You'll probably declare a type e.g.
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 here is to avoid having to deal with readonly types. As the snapshot is not meant to be modified; however, that makes it a pain to use in components. Casting like this should be fairly safe and keeps things ergonomic.
To use within a component we simply call the hook:
const { shapes } = useAppState()
Via the magic of proxies this will subscribe and be updated on changes to that part of the state.
So that is store setup and subscription. How to modify? We create a new function for each modification (can live in same file). It is well typed, trivial to test and can simply be imported and called from components.
export function addShape(shape: IsometricShape) {
appState.shapes.push(shape)
}
2. Transient UI State
Really this is basically the same again except we want to keep some details such as a boolean that determines whether a modal is open or a string uuid that tracks what item if any is selected separate from our main state.
Basically: should an undo happen then app state, if not transient state.
We set up a store (proxy), hooks and functions as above. Valtio means no prop drilling, we can just import the hooks and functions we need in each component. This is really nice for things like modals, as you might have a button to open the modal in one component, but the modal itself is rendered somewhere else.
3. History
This is where things get more interesting. This is very much not trivial.
Valtio used to include a history version of proxy which has now moved to a separate package. You might think you just need to plug this in and job done. But history is subtle, assuming you want to do it well(!) Also it seems with React 19 this doesn't actually work very well any more (or at least I've had issues with the canUndo/canRedo values not updating the component that uses them).
We have already distinguished between state changes and more transient ones. However, that is not really enough. We also need to think about how to batch related changes together. For example if I drag a slider or update a colour picker it might have dozens of intermediate states. Exactly how to do this will be app specific, but the basic pattern will be set up something like:
type HistoryStore = {
history: AppState[];
currentIndex: number;
};
export const historyStore = proxy<HistoryStore>({
history: [],
currentIndex: -1,
});
then separately (this is where the whole Valtio can do stuff outside of React is nice):
subscribe(appState, () => {
pushHistory(appState);
});
we'd implement something like:
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 above samples do have to do a little bit of work to avoid using the original proxy (the cloneDeep and snapshot calls) but otherwise they are fairly simple.
We can make this quite simple to consume via:
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,
};
}
and doing things like setting up keyboard shortcuts is also simple.
Batching
As mentioned before we might want to do batching. One nice thing you can do is track the thing being updated, and if that is the same as the previous update then just replace that part of the history, so a slider being dragged through 100 intermediate states would just result in one new history item. To do this you would modify history: AppState[]; to be an array of history: { state: AppState, target: string }[]; and set and compare suitable targets in the pushHistory function.
This approach means you can present the history (or part of the history) in a UI, which can be a really nice thing to offer for anything graphical.
Summary
Using Valtio's fine-grained reactivity and the ability to subscribe to state changes outside of React components, we've seen how you can implement a powerful, flexible history mechanism with full control over undo/redo functionality.