State Management — Build the Library Yourself
Immutability Is a Performance Device, Not a Preference
In one line
State libraries force immutable updates not because purity is beautiful, but so that "did it change?" can be answered with a single ===.
Start with the problem
Every app that draws state on screen has one question:
What changed, and therefore what needs to be redrawn?
There are only three ways to answer it.
- Redraw everything — correct, but slow
- Do a deep comparison — correct, but the larger the state gets, the more expensive the comparison becomes
- Force anything that changed to become a new object — the comparison is a single
prev === next
Option 3 is immutability. If you promise never to modify state in place, a single reference comparison finishes change detection. It is O(1).
// ❌ 제자리 변경 — 참조가 그대로라 아무도 눈치채지 못한다
state.items.push(x)
// ✅ 새 배열 — 참조가 달라진다
state = { ...state, items: [...state.items, x] }
The symptom of an in-place mutation is "the screen sometimes doesn't update." The data is correct, but nothing renders. It is the hardest kind of bug to track down.
Subscriptions must be released
const unsub = store.subscribe(render)
// ... 컴포넌트가 사라질 때
unsub()
If you don't release them, screens that no longer exist keep being drawn. Open and close a list over and over and the listeners pile up, so a single click triggers hundreds of renders. This is why React's useEffect lets you return a cleanup function, and why Vue gives you onUnmounted.
The code that creates a subscription and the code that releases it should be visible in the same screen. When they sit far apart, one of them is sure to go missing.
If nothing changed, don't notify
dispatch(s => s) // 같은 참조를 돌려줬다
Listeners must not be called in this case. If they are, you get wasted work — "the state is the same, yet a render runs" — and if that render dispatches again, it becomes an infinite loop.
Don't store derived state
This is the most common mistake in state design.
// ❌ 두 개의 진실
{ items: [...], count: 3 }
// ✅ 하나의 진실 + 계산
{ items: [...] }
const count = items.length
If you store it, a moment when the two disagree is sure to come. Somewhere, code updates only items and forgets to update count. That bug shows up as "the number is sometimes wrong," and it can't be reproduced.
The React docs insist that you not synchronize derived state with useEffect for the same reason. If you can compute it, compute it.
So what about when computing is expensive
Memoization. If the input is unchanged, return the previous result as is.
function memo(fn) {
let lastArg, lastOut, has = false
return arg => {
if (has && arg === lastArg) return lastOut // 참조 비교 하나
lastArg = arg; lastOut = fn(arg); has = true
return lastOut
}
}
Here immutability earns its keep again. If the input is immutable, arg === lastArg alone tells you "nothing changed." This optimization doesn't hold for state that is modified in place.
useMemo, reselect, and Vue's computed are all just these three lines.
Batching — change it many times in one tick, render once
dispatch(a); dispatch(b); dispatch(c) // 리스너는 몇 번 불려야 하나?
If it is called three times, the screen is drawn three times. The two middle states are ones the user will never see. So libraries bundle the notifications into a single microtask.
let scheduled = false
function notify() {
if (scheduled) return
scheduled = true
queueMicrotask(() => { scheduled = false; listeners.forEach(l => l()) })
}
This is what React 18's automatic batching is. Before that, setState calls outside event handlers (for example, inside setTimeout) were not batched, so renders ran multiple times.
If you unsubscribe during iteration
Calling unsub() inside a listener is common (listen once, then detach). If you iterate over the array as it is and modify the original while doing so, the next listener gets skipped.
[...listeners].forEach(l => l()) // 복사본을 순회한다
It is one line, but without it you get the bug "sometimes one listener isn't called." Its reproduction rate is low, so finding the cause takes days.
What it looks like in the field — choosing a library
The reason people swap out a state library is usually not "the API is prettier" but that they got bitten by one of the six issues above. Yet if the habit of storing derived values in state, or of never detaching subscriptions, survives in the library you moved to, the same bugs follow you there. This is not a problem that disappears by changing tools.
The criterion for choosing a library is not the shape of its API. It is how it handles the six issues above. Build one yourself once, and every sentence in the docs starts to have a reason.