-
Notifications
You must be signed in to change notification settings - Fork 3
Add guide on idempotent state management patterns #149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
matiasperz
wants to merge
5
commits into
main
Choose a base branch
from
claude/loving-brown-70sqhq
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+262
−0
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
47a3cf6
Add log 16: Idempotent State Management
claude 2763d5b
Simplify state machine diagram in log 16
claude 7bd6d36
Swap fetch state machine for a UI example in log 16
claude 0268731
Explain the server-side idempotency-key mechanism in log 16
claude c4ffec7
Drop idempotency-key section from log 16
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.