Dhaka, Bangladesh
Case Studies

When a Screen's Data Outgrows Its Own Update Code

Cover image for the case study "When a Screen's Data Outgrows Its Own Update Code"
Three moves: a placement gains a second independent list, manual updates can't reach it safely, Immer replaces the update layer without reshaping the data.
I built the state layer for a WooCommerce recommendation builder after placements needed more than one independently configured recommendation list - a nesting the existing update code couldn't safely reach. Rather than flatten the state to avoid the problem, I kept it shaped the way the product worked and adopted Immer to edit it safely.
One nested level added, one dependency adopted, one feature unblocked.
TL;DR summary
The problemA placement needed several independent recommendation lists, not one - the state gained a layer of nesting the update code wasn't built for
What I didKept the nested shape (it matched the product) and adopted Immer so updates could be written as direct edits
OutcomeUnblocked the "add another list" feature, which had existed as non-functional placeholder UI until the state layer caught up
Trade-offA new dependency, and a mutation-looking convention that only works if every update goes through the functions Immer wraps
A single placement marker on a storefront wireframe expanding into two separate list cards, each with its own rule chips and sort order.
The builder shows a merchant a live representation of their storefront - product page, cart, checkout, shop - with editable markers at every point a recommendation block can appear. Early on, each marker held one rule set: one filter, one sort order, one list of products. Then the requirement changed. A single placement needed to serve more than one audience at once - a different list for a first-time visitor than for someone who'd already added something to their cart, both live at the same spot on the page. That's a new layer of nesting in the data: page, then placement, then a list of independently configured lists, each with its own rules and sort order.
A shallow two-level object spread next to a much taller, error-prone spread reaching a third nested level.
Before this, updating a placement's rules meant spreading the whole state object, then the page inside it, then swapping in the new value - a small pyramid of nested spreads that already felt fragile with two levels of nesting. Adding a third level - one list, inside one placement, inside one page - meant that pattern stopped being something you could safely hand-write without missing a sibling field or updating the wrong branch. The gap was visible directly in the UI before the fix: the control for adding another list to a placement existed, but it rendered the raw current state as text and its click handler did nothing. The interface had gotten ahead of a state layer that could actually support it.
Three options compared on rework cost and whether they fix the root cause: keep hand-writing spreads, flatten the state, keep the shape and adopt Immer - chosen.
Keep hand-writing the spreads. No new dependency, nothing else changes. But every update reaching the new nested list would need to reconstruct three levels of object correctly, every time - a lot of surface area for a small mistake to hide in. Flatten the state instead of nesting it. Normalize everything into flat, id-referenced lookups so an update targets one entry directly. This is the more textbook fix for a deep-update problem - but it would have meant reshaping every component already built against the nested tree, mid-feature, while the requirements were still moving. I didn't take this path at the time. Keep the shape, fix how updates are written. The nested structure - page, placement, list - matched how a merchant actually thinks about the builder. Instead of reshaping the data, I adopted Immer and rewrote the update functions to read like direct edits to a draft object, letting Immer produce the correctly copied, still-immutable result underneath.
Draft syntax looks safe to use anywhere - it only is, inside the wrapped update functions.
I went with the third option. The state's shape wasn't the actual problem - writing safe updates against it was - so reshaping the whole tree felt like fixing something adjacent to the real issue. Immer let me solve the update-ergonomics problem directly, without touching how the rest of the builder already read from that tree. It isn't free. Draft-mutation syntax reads like you're allowed to mutate state anywhere, when it only works safely inside the functions Immer wraps. That's a convention the rest of the codebase has to follow consistently - and I hadn't finished converting every part of the builder to it by the time I moved off this work.
The same add-a-list control shown twice: first as a raw text dump with a disabled button, second as a working dropdown that adds a fully formed list.
The clearest before-and-after is that "add another list" control. Before this change, it rendered the state as raw text and did nothing when clicked. After, it reads the available rule types and, on selection, adds a fully-formed new list to the placement through the new update path - the same change that introduced the pattern is the one that made the feature work. No hard metrics exist for this one - no bug count before and after, no time-to-ship comparison for later features built on this state. What I can say directly is that the update pattern and the first working version of "add another list" shipped together: the pattern was adopted to unblock a specific feature, not refactored on its own.
One shared root problem branching into two answers: keep the tree and use Immer, or flatten the tree and reference by id.
The builder I handed off was later reworked, including its state layer. The version that replaced mine didn't keep the nested tree - it flattened the data into id-referenced lookups instead, the option I'd considered and set aside earlier. Both are legitimate answers to the same problem: mine kept the shape that matched the product and fixed the update code; the later version reshaped the data so plain updates were enough on their own. Neither proves the other wrong - they're two standard responses to the same kind of nesting pressure, and seeing both side by side is a better answer than either one alone would have been.
Four decisions, each paired with its one-line reason.
Why keep the nested shape instead of flattening the state, if flattening avoids the deep-update problem entirely? Because the nesting matched how the product actually worked - page, placement, list - and reshaping it mid-build would have meant rewriting every component already reading from that tree for a feature whose scope was still moving. Fixing how updates were written was the smaller, more targeted change at the time. Why Immer specifically? The app didn't already use a state-management library, so anything I picked had to be light enough to justify for one feature. Immer let me keep writing updates that looked like direct edits without giving up immutability underneath, with almost no change to the rest of the code. Why wasn't the Immer convention fully adopted across the builder before handoff? The update path was working for the feature it was built to unblock, but I moved to other priorities before converting every part of the builder to use it consistently. Why did the team end up flattening the state anyway, later? That was a different, and also reasonable, answer to the same nesting problem. I don't think either approach was wrong - they're the two standard ways to keep deep updates safe, and the team chose the other one once they took the builder further than I did.
Part of the Product Recommendations admin builder case study series →
On this page
Quick answer
TL;DR
The situation
Why the existing update code didn't scale
What I considered
The trade-off
The result
What replaced it
FAQ