The setState Inside setState Bug: How a Small Refactor Caused an Infinite Loop

A few days ago, our staging environment started getting slow. There was no obvious expensive computation, unusually large API response, or memory leak that immediately stood out. The culprit turned out to be a small refactor in a Zustand store that introduced a set() call inside another set() call.
At first glance, the change looked harmless. The update logic had simply been extracted into a reusable store method. Instead, the update was applied, immediately reverted, and then applied again. Because the update was triggered by an <img> error event, that revert created a feedback loop that kept the browser retrying the same broken image.
The result was a seemingly simple state management bug turning into an infinite loop and noticeable UI lag.
This is the story of how it happened.
The Setup
We use Zustand for state management, together with the Immer middleware. Immer lets us write state updates in a mutable looking style:
set((state) => {
state.someValue = newValue;
});
Under the hood, Immer creates a draft, tracks mutations to that draft, and produces the next state from those changes.
Our store had state for image URLs. When an image failed to load, the application would calculate a fallback URL and store it. The flow was straightforward: an image failed, its error event triggered a store action, the action replaced the URL with a fallback, and the component rendered the new URL.
Nothing unusual so far.
The Refactor
The original implementation performed the whole update inside a single set() call.
Here is a simplified version. This is intentionally not the real project code because the real project is under NDA.
Before:
export const createImageSlice = (set, get) => ({
handleImageError(imageId) {
set((state) => {
const entry = state.imagesById[imageId];
if (entry) {
entry.url = get().getFallbackUrl();
} else {
state.imagesById[imageId] = {
url: get().getFallbackUrl(),
};
}
});
},
});
The code was then refactored to extract the fallback update into a reusable store method:
After:
export const createImageSlice = (set, get) => ({
handleImageError(imageId) {
set((state) => {
get().setFallbackUrl(imageId);
});
},
setFallbackUrl(imageId) {
set((state) => {
const entry = state.imagesById[imageId];
if (entry) {
entry.url = get().getFallbackUrl();
} else {
state.imagesById[imageId] = {
url: get().getFallbackUrl(),
};
}
});
},
});
At a glance, the refactor makes sense. The fallback logic now has its own method, the original action becomes smaller, and the code looks more reusable.
It also passed code review.
And that was where the bug entered the system.
Why This Broke
The problem is the relationship between the two set() calls. To understand why the update was reverted, we need to follow what happens to the Immer drafts.
1. The outer set() starts
handleImageError() calls:
set((state) => {
get().setFallbackUrl(imageId);
});
Zustand's Immer middleware gives the callback an Immer draft representing the current state. At this point, the outer recipe owns that draft.
2. The outer recipe calls another store action
Inside that recipe, we call:
get().setFallbackUrl(imageId);
This is important because get() gives us the store's current state and actions. It does not give setFallbackUrl() access to the draft owned by the outer set().
So setFallbackUrl() starts a completely separate state update.
3. The inner set() creates its own draft
Inside setFallbackUrl() we have another:
set((state) => {
// mutate state
});
This creates a second Immer draft. The inner recipe mutates that draft, so Immer produces a new state object and Zustand applies it.
The fallback URL is now in the store.
So far, everything looks correct.
4. Control returns to the outer recipe
The inner set() finishes and returns control to the outer callback. However, the outer Immer draft was never mutated.
All the mutations happened to the inner draft, which belongs to a different set() call. The outer recipe therefore finishes without making any changes to its own draft.
5. Immer sees no mutation
This is the part that makes the bug particularly confusing.
When an Immer producer does not modify its draft and does not explicitly return a replacement state, there is no new state to produce from that recipe. Immer can therefore return the original state object.
In our case, that original state is the state that existed when the outer set() started. That state does not contain the fallback URL yet.
6. The outer set() applies that old state
Zustand then receives the result of the outer recipe. The inner update that had just been applied is effectively overwritten by the outer update.
The state goes through this sequence:
old state
↓
inner set()
↓
fallback URL applied
↓
outer set() finishes without mutating its draft
↓
original state returned
↓
fallback URL reverted
The result is a silent state revert. The fallback URL was successfully written, only to be immediately replaced by the previous state.
On its own, this would already be a nasty bug. But it wasn't the reason the application became slow. For that, we need to look at what triggered the store action.
From a Silent Revert to an Infinite Loop
The state update was triggered by an image loading failure.
Our Lit component was effectively doing this:
render() {
return html`
<img
src=${this.imageUrl}
@error=${this._onImageError}
/>
`;
}
The silent revert would have been a relatively contained state bug if nothing reacted to the reverted value. But our <img> element did.
Here's what happened:
- The image fails to load.
- The browser fires the
errorevent. - The event handler calls
handleImageError(). - The nested
set()applies the fallback URL. - The outer
set()finishes without mutating its own draft. - Immer returns the original state.
- The broken URL is restored.
- Lit renders the
<img>with the broken URL again. - The browser tries to load it again.
- The image fails again and fires another
errorevent.
Then the cycle starts over.
This was the missing piece. The nested set() explains why the state was reverted, while the <img> error handler explains why the revert became an infinite loop.
A one time state management bug became a feedback loop because the reverted state was itself capable of triggering the same event that caused the update. That is what turned a silent correctness bug into a performance problem in staging.
The Fix
The main rule we took away was simple:
Don't call a
set()based store action from inside anotherset()callback.
There are a couple of ways to structure the code safely.
Option A: Keep helpers separate from state actions
If the goal is to avoid duplicating mutation logic, extract the mutation into a plain helper function.
function applyFallbackUrl(state, imageId, fallbackUrl) {
const entry = state.imagesById[imageId];
if (entry) {
entry.url = fallbackUrl;
} else {
state.imagesById[imageId] = {
url: fallbackUrl,
};
}
}
export const createImageSlice = (set, get) => ({
handleImageError(imageId) {
const fallbackUrl = get().getFallbackUrl();
set((state) => {
applyFallbackUrl(state, imageId, fallbackUrl);
});
},
setFallbackUrl(imageId) {
const fallbackUrl = get().getFallbackUrl();
set((state) => {
applyFallbackUrl(state, imageId, fallbackUrl);
});
},
});
Now both actions own their set() call. The helper only operates on the draft it receives and does not start another state update.
There is no nested set().
Option B: Resolve data first, mutate once
Another useful pattern is to separate data preparation from state mutation.
If one action needs information from another piece of logic, resolve that information first and pass plain data into the state update:
const fallbackUrl = get().getFallbackUrl();
set((state) => {
applyFallbackUrl(state, imageId, fallbackUrl);
});
The important distinction is that getFallbackUrl() happens before the set() callback starts. The callback is then responsible only for mutating its own draft.
In other words, the flow should be:
calculate
↓
prepare data
↓
set()
↓
mutate this draft
rather than:
set()
↓
call another action
↓
another set()
↓
mutate a different draft
What We Changed on the Team
The interesting part of this bug is that the problematic code looked reasonable during review. That made the prevention work more useful than simply fixing this particular method.
We made a few changes:
- Added a code review checklist item for nested
set()calls. - Started looking at an ESLint rule or custom lint check for
get().someStoreAction()inside aset()callback. - Added a short explanation to our onboarding documentation about how Immer handles producers that do not mutate their draft.
- Started paying more attention to event handlers that can feed state changes back into the DOM event that triggered them.
That last point is easy to overlook. A state management bug can become a completely different class of bug when the state is connected to a browser event. An event that looks like an endpoint can actually be a feedback mechanism.
Takeaway
The final bug was only a few lines of code.
The difficult part was understanding how those lines interacted with three different systems: Zustand, which applies state updates synchronously; Immer, which determines whether a producer produced a new state based on what happened to its draft; and the browser and DOM, where an <img> failure can fire another event whenever the broken URL is rendered again.
None of these systems behaved incorrectly. The problem came from the way they interacted.
The nested set() caused an update to be applied and then silently reverted. The <img> error event turned that revert into a retry condition, and the retry condition turned a small state management mistake into an infinite loop.
That is probably the biggest lesson I took from this bug:
When refactoring state management code, don't only ask whether the final state is correct. Ask which draft is being mutated, when each update is committed, and whether the resulting state can trigger the event that caused the update in the first place.
Sometimes the hardest production bugs are not caused by one complicated piece of code. They happen when several simple pieces of code form a loop.