Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 262 additions & 0 deletions content/logs/16-idempotent-state-management.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
---
title: 16 - Idempotent State Management
description: Why a state update that runs twice should land in the same place as running once, and the client-side patterns that get you there — keyed sets, absolute values, and state machines.
author: 'matiasperz'
---

A user double-clicks "Add to cart". The network hiccups and your fetch retries. React's `StrictMode` fires your effect twice in dev. A WebSocket reconnects and replays the last few messages. None of these are exotic — they're Tuesday. And every one of them runs the same state update more than once.

The question that decides whether that's fine or a bug: **if this update runs twice, do you end up in the same place as running it once?**

If yes, your update is **idempotent**. If no, you've got a duplicate item in the cart, an unread count that keeps climbing, and a Slack thread titled "can't reproduce".

```ts
// Idempotent: same result no matter how many times it runs
user.role = 'admin'
user.role = 'admin'
user.role = 'admin'
// user.role === 'admin'

// Not idempotent: the result depends on how many times it ran
user.loginCount += 1
user.loginCount += 1
user.loginCount += 1
// user.loginCount === 3, when you meant 1
```

That's the whole idea. The interesting part is that real state updates run an unpredictable number of times, so "runs exactly once" is an assumption you don't actually get to make.

```mermaid
flowchart LR
A(["User clicks 'Add to cart'"]) --> B["Event dispatched"]
B --> C{"Network<br/>reliable?"}
C -- Yes --> D["Processed once"]
C -- No --> E["Retried 3 times"]
D --> F["Cart: 1 item"]
E --> G{"Idempotent?"}
G -- Yes --> F
G -- No --> H["Cart: 3 items"]

style F fill:#bbf7d0,stroke:#16a34a,color:#000
style H fill:#fecaca,stroke:#dc2626,color:#000
style G fill:#fef9c3,stroke:#ca8a04,color:#000
```

## The traps

Almost every non-idempotent bug comes from the same root: the update describes a **change** instead of a **destination**. Three flavors show up over and over.

**Relative updates.** The classic append:

```ts
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] } // appends every time
```

Fire `ADD_ITEM` twice for the same product and you've got two of them. The state tracks how many times the action ran, not what the user wanted.

**Toggles.** Logic that flips based on the current value:

```ts
function toggleFavorite(state, productId) {
if (state.favorites.includes(productId)) {
return state.favorites.filter((id) => id !== productId)
}
return [...state.favorites, productId]
}
```

Once: favorited. Twice: unfavorited. Three times: favorited again. The outcome is a function of the call count, which is exactly the thing you don't control.

**Increments.** Anything that accumulates:

```ts
dispatch({ type: 'INCREMENT_UNREAD', count: 1 })
```

Let a reconnection replay that event and your unread badge keeps ticking up while no new messages arrived.

```mermaid
flowchart TD
subgraph nonIdem ["Non-Idempotent"]
N1["State: 0 items"] --> N2["ADD_ITEM (shirt)"]
N2 --> N3["State: 1 item"]
N3 --> N4["ADD_ITEM (shirt)<br/>(retry)"]
N4 --> N5["State: 2 items"]
end

subgraph idem ["Idempotent"]
I1["State: 0 items"] --> I2["SET_ITEM (shirt, id:abc)"]
I2 --> I3["State: 1 item"]
I3 --> I4["SET_ITEM (shirt, id:abc)<br/>(retry)"]
I4 --> I5["State: 1 item"]
end

style N5 fill:#fecaca,stroke:#dc2626,color:#000
style I5 fill:#bbf7d0,stroke:#16a34a,color:#000
style nonIdem fill:#fef2f2,stroke:#dc2626,color:#000
style idem fill:#f0fdf4,stroke:#16a34a,color:#000
```

## The fix is always the same shape

Stop describing the delta. Describe the destination. Three patterns cover basically everything.

**Key by identity, not position.** Arrays append; maps overwrite. Swap the array for an object keyed by a unique id and duplicates collapse on their own:

```ts
case 'SET_ITEM':
return {
...state,
items: { ...state.items, [action.payload.id]: action.payload }, // same id, same slot
}
```

Dispatch `SET_ITEM` with the same id ten times and you get one item. The id does the deduplication for free.

**Declare intent instead of toggling.** Split the toggle into two explicit actions that each name a target state:

```ts
case 'SET_FAVORITE':
return { ...state, favorites: new Set([...state.favorites, action.payload.id]) }
case 'UNSET_FAVORITE': {
const next = new Set(state.favorites)
next.delete(action.payload.id)
return { ...state, favorites: next }
}
```

Adding an id that's already in a `Set` is a no-op. Deleting one that isn't there is a no-op. Both run safely as many times as you like.

**Set absolute values, not deltas.** When a number comes from somewhere authoritative, take the number, not the change:

```ts
// Not idempotent
dispatch({ type: 'INCREMENT_UNREAD', by: 5 })

// Idempotent — the server already knows the answer
dispatch({ type: 'SET_UNREAD_COUNT', count: 12 })
```

Don't recompute locally what the server already knows. Let it be the source of truth and just mirror it.

## State machines get it for free

For anything with distinct modes — a media player, a wizard, a dialog — you don't have to bolt idempotency on. A state machine has it built in: a transition from state A on event X always lands in state B, no matter how many X's you throw at it.

```mermaid
stateDiagram-v2
direction LR
[*] --> idle
idle --> playing : PLAY
playing --> playing : PLAY (no-op)
playing --> paused : PAUSE
paused --> playing : RESUME
```

Notice the self-loop: `PLAY` while already `playing` just keeps you `playing`. Hammer the play button, fire the event off a key repeat, replay it from a restored session — the machine stays put. No double playback, no stacked side effects. It guards itself.

```ts
type PlayerState = 'idle' | 'playing' | 'paused'

type PlayerAction = { type: 'PLAY' } | { type: 'PAUSE' } | { type: 'RESUME' }

function playerReducer(state: { status: PlayerState }, action: PlayerAction) {
switch (state.status) {
case 'idle':
if (action.type === 'PLAY') return { status: 'playing' as const }
return state // nothing else means anything from idle

case 'playing':
if (action.type === 'PAUSE') return { status: 'paused' as const }
return state // PLAY again while playing? no-op

case 'paused':
if (action.type === 'RESUME') return { status: 'playing' as const }
return state
}
}
```

The trick is the `return state` lines: events that don't fit the current state are ignored. Duplicates literally can't do anything.

## It doesn't stop at the client

The same principle travels past the browser. Anywhere a write can run more than once — a backend handler behind a retrying client, a queue consumer redelivering a message, a webhook that fires twice — wants the exact property we've been chasing: run it again, land in the same place. Servers have their own machinery for getting there, but the mental model doesn't change from the one you'd use in a reducer — describe the destination, key by identity, let repeats be no-ops. Worth knowing it's a shared concern so both sides agree on what "done" means. The leverage for a frontend dev, though, is almost always in the client state itself — so that's where we'll keep our attention.

## Putting it together

Here's a cart reducer where every operation is idempotent by construction — keyed items, absolute quantities, and a tiny state machine for the save flow:

```tsx showLineNumbers
'use client'

type CartItem = {
id: string
name: string
price: number
quantity: number
}

type CartState = {
items: Record<string, CartItem> // keyed by id, not an array
status: 'idle' | 'saving' | 'saved' | 'error'
}

type CartAction =
| { type: 'SET_ITEM'; item: CartItem }
| { type: 'REMOVE_ITEM'; id: string }
| { type: 'SET_QUANTITY'; id: string; quantity: number }
| { type: 'SAVE_START' }
| { type: 'SAVE_SUCCESS' }
| { type: 'SAVE_ERROR' }

function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'SET_ITEM':
// same id always lands in the same slot
return { ...state, items: { ...state.items, [action.item.id]: action.item } }

case 'REMOVE_ITEM': {
// removing a missing key does nothing
const { [action.id]: _, ...rest } = state.items
return { ...state, items: rest }
}

case 'SET_QUANTITY':
// absolute quantity, never a delta
if (!state.items[action.id]) return state
return {
...state,
items: {
...state.items,
[action.id]: { ...state.items[action.id], quantity: action.quantity },
},
}

case 'SAVE_START':
return { ...state, status: 'saving' }
case 'SAVE_SUCCESS':
return { ...state, status: 'saved' }
case 'SAVE_ERROR':
return { ...state, status: 'error' }

default:
return state
}
}
```

Replay any of these as many times as you want — same input, same state. That's the whole property.

## Rules of thumb

| Rule | Why |
|------|-----|
| **Key everything by unique id** | Maps and Sets deduplicate on their own. Arrays don't. |
| **Set absolute values, not deltas** | `SET_COUNT(5)` is idempotent. `INCREMENT(1)` is not. |
| **Replace toggles with explicit intent** | `SET_FAVORITE` / `UNSET_FAVORITE` over `TOGGLE_FAVORITE`. |
| **Use state machines for mode-based flows** | Impossible states become unrepresentable; duplicate events become no-ops. |
| **Trust the server for derived values** | Don't locally compute what it already knows. |

The shortcut for all of it: if you can answer *"what should the state be?"* instead of *"how should the state change?"*, you're writing idempotent code. Worth doing anytime an update can fire more than once — clicks, effects, sockets, retries, optimistic updates. Less worth the ceremony when you're rendering pure UI from props or just mirroring a single source of truth with no local mutation.
Comment thread
matiasperz marked this conversation as resolved.