Automatic Rollback Data in Optimistic Updates: A surprising benefit of Normalized Data

When building modern web applications, optimistic updates are a crucial pattern for creating responsive user experiences. Instead of waiting for server responses, we update the UI immediately while the actual operation happens in the background. However, this pattern comes with a challenge: what happens when things go wrong?
Traditionally, handling failed optimistic updates requires manually tracking and storing the previous state for rollback. This can be error-prone and often leads to complex, boilerplate-heavy code.
Interestingly, in recent years I worked on normalization library. The interesting thing is, that my reasons were mostly to store data in normalized store to allow uutomatic data updates. If you know how Apollo handles mutations and automatically updates queries based on mutation responses, you exactly know what I am talking about. The funny thing is, that recently I realized, that having normalized store allows other benefits, for example, we could automate entire process of reverting data after error after optimistic updates!
The Traditional Approach
Let's look at how optimistic updates are typically handled, let's use @tanstack/react-query
as example:
const updateTodoMutation = useMutation({
mutationFn,
onMutate: async newTodoPartial => {
// 1. Manually snapshot the previous value
const previousTodo = queryClient.getQueryData(['todos', '1']);
// 2. Optimistically update
queryClient.setQueryData(['todos', '1'], old => ({...old, ...newTodoPartial });
// 3. Return rollback data
return { previousTodo };
},
onError: (err, newTodoPartial, context) => {
// 4. On error, rollback to the previous value
queryClient.setQueryData(['todos', '1'], context.previousTodo);
},
});
This approach has several issues:
You need to manually track what data might be affected, updated object can live in numerous queries!
Handling nested relationships is complex
If you are interested only in updating part of an object, you need to still get full current value of this object to update it
You need to provide data to rollback manually
Enter normalized data
Data normalization is typically associated with reducing data duplication and maintaining consistency. This allows us to simplify the above code like this:
const updateTodoMutation = useMutation({
mutationFn,
onMutate: async newTodoPartial => ({
optimisticData: newTodoPartial,
}),
});
That's it! All you need to do is passing object with updated values as optimisticData and you are covered! When having data normalized, all you need is some partial object, like { id: 1, name: 'Todo updated name' }
. The rest can be figured out automatically, including reverting to previous object in case of an error.
The key insight is that with normalized data:
We have a single source of truth for each entity
We can use the structure of the optimistic update to find the current state
The normalized store maintains all relationships between entities
When an optimistic update comes in, we:
Look at the structure of the optimistic data
Find those same objects in our normalized store
Create a perfect snapshot of the current state
Store it as rollback data automatically
The Benefits
This approach provides several advantages:
Zero Manual Tracking: No need to manually figure out what data needs to be saved for rollback
Perfect Structural Matching: The rollback data will always match the structure of the optimistic update
Handles Complex Relationships: Because the data is normalized, we automatically handle nested relationships
Reduced Boilerplate: Your mutation code becomes much simpler:
How Does It Work?
The above snippet works thanks to addon, which adds automatic normalization and data updates to fetching libraries, like react-query
. In this case, you can see related snippet from this library, which implements this feature:
queryClient.getMutationCache().subscribe(event => {
if (
event.type === 'updated' &&
event.action.type === 'success' &&
event.action.data &&
shouldBeNormalized(normalize, event.mutation.meta?.normalize)
) {
updateQueriesFromMutationData(
event.action.data as Data,
normalizer,
queryClient,
);
} else if (
event.type === 'updated' &&
event.action.type === 'pending' &&
(event.mutation.state?.context as { optimisticData?: Data })?.optimisticData
) {
const context = event.mutation.state.context as {
optimisticData: Data;
rollbackData?: Data;
};
if (!context.rollbackData) {
const rollbackDataToInject = normalizer.getCurrentData(
context.optimisticData,
);
normalizer.log(
'calculated automatically rollbackData:',
rollbackDataToInject,
);
context.rollbackData = rollbackDataToInject;
}
updateQueriesFromMutationData(
context.optimisticData,
normalizer,
queryClient,
);
} else if (
event.type === 'updated' &&
event.action.type === 'error' &&
(event.mutation.state?.context as { rollbackData?: Data })?.rollbackData
) {
updateQueriesFromMutationData(
(event.mutation.state.context as { rollbackData: Data }).rollbackData,
normalizer,
queryClient,
);
}
});
Conclusion
Data normalization isn't just about maintaining consistency and reducing duplication. It can also provide powerful abstractions that simplify complex operations like optimistic updates and rollbacks.
By leveraging the structure of our normalized data, we can automate what was previously a manual, error-prone process. This not only reduces code complexity but also makes our applications more robust and easier to maintain.
The next time you're considering whether to normalize your application's data, remember this hidden superpower – it might just make your life a lot easier!
And... I even wonder, what comes next? Maybe there are even other benefits which are about to discover?
Want to try this out? Check out Normy - a library that implements these patterns and more!
Subscribe to my newsletter
Read articles from Konrad Lisiczyński directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
