Skip to content
Chapter 61Lesson 3

Version columns and the honest 409

Optimistic concurrency control, a version column and a precondition in the UPDATE that catch two users clobbering each other's edits and turn the lost write into a recoverable 409 conflict.

Two people on the same team open the same invoice. Alice and Bob both click “Edit,” and both forms load with amount: 100. Alice bumps the amount to 150 and saves, and the database now holds 150. A minute later Bob, still looking at his form that says 100, fixes a typo in the customer note and saves. His save writes the whole form back: the corrected note, and amount: 100, because that’s still what his stale form held. Alice’s 150 is gone. No error fired, nothing was logged. Alice finds out three days later, when the invoice is short fifty dollars and nobody can explain why.

This bug never shows up in a demo, because a demo has only one tab open. It shows up in production the first week real users edit the same records, and it is hard to diagnose after the fact, because there’s no exception and no stack trace, just a value that quietly reverted. By the end of this lesson you’ll know why it happens, how a single extra WHERE condition detects it, and how to turn that detection into a moment where the user sees what changed and decides what to do, instead of silently overwriting a coworker’s work.

You already have most of the machinery. In the previous lessons of this chapter, your lifecycle actions put predicates in the UPDATE’s WHERE: the tenant scope rides in ctx.db, and the lifecycle filters keep you off deleted rows. This lesson adds one more predicate to that same clause, plus the React surface that makes its failure honest. That is the whole lesson: one WHERE condition and the UX around it.

Let’s slow the loss down and watch it frame by frame. What makes it dangerous is timing: the two writes interleave in a way that’s invisible from inside either tab. Each tab does something completely reasonable on its own, and the damage exists only in the gap between them.

Step through the sequence below. There are two browser tabs and one database. Watch what the database holds after each write, and watch the moment the loss happens.

%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
  participant A as Tab A
  participant B as Tab B
  participant DB
  Note over DB: amount = 100
  rect rgba(129, 140, 248, 0.18)
  A->>DB: read invoice
  DB-->>A: amount = 100
  B->>DB: read invoice
  DB-->>B: amount = 100
  end
Both tabs open the invoice. Each reads amount = 100. Both forms now hold a value that was true at read time.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
  participant A as Tab A
  participant B as Tab B
  participant DB
  Note over DB: amount = 100
  A->>DB: read invoice
  DB-->>A: amount = 100
  B->>DB: read invoice
  DB-->>B: amount = 100
  rect rgba(52, 211, 153, 0.20)
  A->>DB: UPDATE amount = 150
  DB-->>A: ok
  end
  Note over DB: amount = 150
Tab A saves 150. The database now holds 150. From Tab A's point of view, everything is correct.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
  participant A as Tab A
  participant B as Tab B
  participant DB
  Note over DB: amount = 100
  A->>DB: read invoice
  DB-->>A: amount = 100
  B->>DB: read invoice
  DB-->>B: amount = 100
  A->>DB: UPDATE amount = 150
  DB-->>A: ok
  Note over DB: amount = 150
  rect rgba(248, 113, 113, 0.22)
  B->>DB: UPDATE amount = 100
  DB-->>B: ok
  end
  Note over DB: amount = 100 (A's edit gone)
Tab B saves. Its form still holds the stale 100 it read in step 1, so its UPDATE writes amount = 100, overwriting Tab A's 150. The database is back to 100.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
  participant A as Tab A
  participant B as Tab B
  participant DB
  Note over DB: amount = 100
  A->>DB: read invoice
  DB-->>A: amount = 100
  B->>DB: read invoice
  DB-->>B: amount = 100
  A->>DB: UPDATE amount = 150
  DB-->>A: ok
  Note over DB: amount = 150
  B->>DB: UPDATE amount = 100
  DB-->>B: ok
  Note over DB: amount = 100 (A's edit gone)
  rect rgba(248, 113, 113, 0.22)
  Note over A,DB: silent data loss
  end
Tab A's edit is gone. No error was raised, nothing was logged. Both writes 'succeeded'. This is last-write-wins, and it's the default behavior of every naive UPDATE.

What you just watched has a name: last-write-wins . It is the default behavior of any UPDATE ... SET ... WHERE id = ?. The database did exactly what it was told both times. The problem isn’t the database; it’s that Bob’s UPDATE had no way to know his form was built from a value that had since moved on.

Notice what each tab experienced: success. Both saves returned ok. The loss lives entirely in the interleaving, in the gap between Bob’s read and Bob’s write, and that gap is exactly where a second writer can slip in. So we need three things, and the rest of this lesson is these three things in order:

  1. A precondition that detects when the second writer is working from a stale read.
  2. A response that lets that writer recover by seeing the current value and deciding what to do, instead of losing their work or someone else’s.
  3. The judgment to know when this is overkill, because adding it to every write is its own kind of mistake.

Optimistic concurrency: check before you write

Section titled “Optimistic concurrency: check before you write”

Before any code, make the decision. There are two classic strategies for stopping two writers from clobbering each other. Picking the right one is the senior call here; the implementation is almost an afterthought once you’ve chosen.

The first is pessimistic locking. When Bob reads the row to edit it, the database locks that row (SELECT ... FOR UPDATE) and holds the lock until Bob saves. Alice can’t write while Bob holds it, so she waits. There are no surprises, but consider what you’re holding the lock across: human think-time. Bob opens the form, goes to get coffee, takes a call, then comes back and types. The row stays locked that entire time, and every other editor is blocked behind him. A lock held across a request/response boundary, waiting on a human, is how you get a wedged row, and if the request that was supposed to release the lock dies, the lock can linger. This is the wrong tool for web traffic, where “the user is slowly typing into a form” is the normal case.

The second is optimistic concurrency . There is no lock at all. Bob reads the row and its version, and he takes as long as he likes. When he saves, his UPDATE says, in effect, “write this, but only if the version is still what I read.” If someone wrote in the meantime, the version moved, the condition fails, and Bob’s write is rejected so he can deal with it. You’re betting that collisions are rare, and in typical SaaS editing they are: two people rarely edit the same invoice within the same sixty seconds. So you pay nothing on the common path and pay a small price only on the rare miss.

So you go with optimistic concurrency. Here is the mechanism in its simplest form: a single integer that counts how many times the row has been written.

You add one column:

export const invoices = pgTable('invoices', {
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
orgId: uuid().notNull(),
amount: numeric({ precision: 12, scale: 2 }).notNull(),
version: integer().notNull().default(1),
...lifecycleColumns,
});

The protocol around that column is four steps, and it’s worth stating them plainly, because everything downstream is just this:

  1. The client reads the row and its version, say version: 7.
  2. The client holds that 7 and sends it back when the user saves.
  3. The UPDATE does two things in one atomic statement: it checks WHERE version = 7, and in its SET it does version = version + 1.
  4. You read how many rows the UPDATE touched. One row means the write landed: nobody raced you, and the version was still 7. Zero rows means the version moved: someone wrote between your read and your write, bumping it past 7, so your WHERE matched nothing. Zero rows is the conflict.

That fourth point is the whole trick, and it’s easy to underrate: the number of rows affected is the signal. A successful precondition write touches one row. A failed one touches zero, not because the row is gone, but because no row matched version = 7 anymore. You don’t need a separate “did this conflict?” query. The UPDATE answers the question itself.

One note on the column type, because it’s a real decision. Use an integer, not a UUID or a timestamp, for the version itself. It’s small, it’s ordered, and version + 1 is an atomic increment the database does in place. There’s a separate, tempting idea: using the updatedAt timestamp you already have as the precondition, instead of adding a column at all. That’s a different question, and it comes next.

The updatedAt precondition: when you can’t add a column

Section titled “The updatedAt precondition: when you can’t add a column”

Here’s a fair objection: you already added an updatedAt column in this chapter’s first lesson, and Drizzle’s $onUpdate already stamps it on every write. So you already have a value that changes on every UPDATE. Why add a second one? Couldn’t the precondition just be WHERE updatedAt = :clientUpdatedAt?

It can, and sometimes it should. But the two approaches make different trade-offs, and the difference is worth understanding so you can choose deliberately rather than out of habit.

.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)

The course default for structured editing. An explicit, dedicated counter: one extra column, but immune to timestamp-precision problems and unambiguous to read, since version = 7 means exactly one thing. Reach for this on any multi-field edit form.

A point of confusion is worth heading off directly: people hear “timestamp precondition” and immediately worry about clock skew between machines. That worry is misplaced here. The updatedAt value is written by the database and read back from the database, so the browser’s clock never touches it. There are no two clocks to disagree. The real risk with the timestamp approach is purely about serialization: a millisecond getting truncated, or a timezone offset getting mangled, as the value round-trips through your form and back. That’s a bug you can have, but it’s a different bug than the one people instinctively reach for.

The decision rule is simple. Prefer version for structured editing: multi-field forms, drafts, anything a user spends real time in. It’s explicit, and it can’t be defeated by timestamp precision. Reach for updatedAt only when you genuinely can’t add a column, such as a frozen schema or a legacy table you don’t own, and only when its precision is high enough to trust. The course default, and what the rest of this lesson uses, is version.

Now the action. This is where your existing UPDATE, the one whose WHERE already carries tenancy and lifecycle from this chapter’s earlier lessons, gains its third predicate and its conflict branch. It’s one statement, but it has four parts that each do a distinct job, so step through it one part at a time.

export const updateInvoice = authedAction(
'member',
updateInvoiceSchema,
async (input, ctx) => {
const updated = await ctx.db
.update(invoices)
.set({
amount: input.amount,
version: sql`${invoices.version} + 1`,
})
.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)
.returning();
if (updated.length === 0) {
return conflict(await currentInvoice(ctx, input.id));
}
revalidatePath('/invoices');
return ok(updated[0]);
},
);

Same five-seam shape as every action in this chapter. authedAction already parsed the input and checked the role, and ctx.db is already tenant-scoped. We’re at the mutate seam, where everything is the write and its aftermath. There’s nothing new about the wrapper, so focus on the body.

export const updateInvoice = authedAction(
'member',
updateInvoiceSchema,
async (input, ctx) => {
const updated = await ctx.db
.update(invoices)
.set({
amount: input.amount,
version: sql`${invoices.version} + 1`,
})
.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)
.returning();
if (updated.length === 0) {
return conflict(await currentInvoice(ctx, input.id));
}
revalidatePath('/invoices');
return ok(updated[0]);
},
);

The SET clause does the increment: version + 1. This is what makes the next writer’s precondition work. Forget to bump it here, and every save succeeds, the counter never moves, and no conflict is ever detected. The precondition in the WHERE and the increment in the SET are a pair; one is useless without the other. (updatedAt isn’t set here, because $onUpdate from Lesson 1 already stamps it on every UPDATE.)

export const updateInvoice = authedAction(
'member',
updateInvoiceSchema,
async (input, ctx) => {
const updated = await ctx.db
.update(invoices)
.set({
amount: input.amount,
version: sql`${invoices.version} + 1`,
})
.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)
.returning();
if (updated.length === 0) {
return conflict(await currentInvoice(ctx, input.id));
}
revalidatePath('/invoices');
return ok(updated[0]);
},
);

The WHERE carries every precondition in one clause: the row id, the lifecycle filter (isNull(deletedAt), so you don’t edit a deleted row), and the version check. Tenancy rides in ctx.db, so it’s already folded in. The invariant is that every condition deciding whether this write is allowed lives right here. Miss one and you write the wrong row, or the wrong version.

export const updateInvoice = authedAction(
'member',
updateInvoiceSchema,
async (input, ctx) => {
const updated = await ctx.db
.update(invoices)
.set({
amount: input.amount,
version: sql`${invoices.version} + 1`,
})
.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)
.returning();
if (updated.length === 0) {
return conflict(await currentInvoice(ctx, input.id));
}
revalidatePath('/invoices');
return ok(updated[0]);
},
);

.returning() hands back the rows the UPDATE actually touched. Zero rows is the heart of this lesson: it is not a no-op success. It means the version moved between read and write, which is a conflict, so we return conflict(...) carrying the fresh row. One row means we won the race, so we return it as the new state.

1 / 1

The line to hold onto is the branch at the bottom. updated.length === 0 does not mean “nothing needed changing.” It means “someone wrote between your read and your write.” Treating zero rows as a quiet success is exactly how you get back to silent data loss: the write didn’t land, but you told the user it did. Zero rows is a 409.

A couple of things downstream of that, briefly. The sql`${invoices.version} + 1` fragment is raw SQL on purpose, the same sanctioned sql tagged-template carve-out you saw with the partial-index predicate earlier in this chapter. Doing the + 1 in SQL keeps the increment atomic: the database reads and writes the counter in one step, with no chance for a second writer to slip in between. And .returning() is the piece that makes the zero-rows check possible at all:

const updated = await ctx.db.update(invoices).set({ /* … */ }).where(/* … */).returning();
// updated.length === 0 → the version moved; this is a conflict, not a success

One more thing, so you don’t reach for it later and get it wrong: don’t try to enforce the increment with a database trigger (BEFORE UPDATE ... SET version = version + 1). It works, but it hides the most important line of the action, the bump, behind invisible database behavior. Keep the version = version + 1 right there in the SET at the call site, where the next person reading the action can see that this write participates in the optimistic-concurrency protocol. Explicit beats clever here.

Detecting the conflict is half the job. The other half is what you hand back, because a conflict the user can recover from is worth far more than a conflict that’s just an error message.

Your action already returns the canonical Result you’ve used since you first wrote a Server Action: { ok: true, data } or { ok: false, error }, where error carries a code and a userMessage. The conflict case uses two pieces of that, plus one honest extension:

  • code: 'conflict' is the discriminant the form branches on. This is not a new code; 'conflict' is already in the course’s Result error-code union. You’re using an existing slot, not inventing one.
  • A current field carries the fresh server row. This is an extension of the base shape, and it’s the senior detail in this section. When you reject Bob’s write, you also hand him the current state of the row, what it is now, after Alice’s edit. So his UI can show him what changed without making a second round-trip to fetch it.

Why ship current in the rejection instead of letting the client re-fetch? Because a re-fetch after a 409 is both wasteful and racy. It’s a second request you didn’t need, and worse, the value could change again between the rejection and the re-fetch, so you’d be chasing a moving target. You already had to read a fresh row to build the error usefully, so send it along.

Be honest with yourself about the shape here. The base err(code, userMessage, fieldErrors?) helper from the Result lesson does not carry a current field; its third argument is fieldErrors, for form-field messages, which is a different thing entirely. So this lesson is genuinely extending the Result for the conflict case, and the clean way to do that is a small dedicated helper that adds current as its own field alongside the standard error:

const conflict = <T>(current: T) =>
({
...err('conflict', 'This invoice was changed in another tab. Refresh to see the latest version.'),
current,
});

It spreads the standard err(...) result and adds current as a sibling field, so the type is “the normal failure Result, plus a current payload.” That keeps the call site readable, return conflict(currentRow), and confines the divergence from the base helper to one place. The point to hold onto is that current is an honest add-on you’re choosing to ship, not something the base err() quietly already gave you.

One meaning, two transports. Everything above is the in-app path: a Server Action returns the Result object directly to your React form, and no HTTP status code ever reaches the client, because a Server Action isn’t an HTTP call the client sees. But the same conflict can reach you through a different door. If an external integrator or a mobile client hits a route handler that wraps this same logic, you don’t hand them a Result object; you return 409 Conflict with an RFC 9457 Problem Details body. It’s the same underlying meaning, “your write lost the race,” expressed in whichever vocabulary the caller speaks. The in-app form gets the typed Result; the external caller gets the standard status code. This is the canonical mapping, not an invention: the project’s route-handler status table already pins 409 to “conflict.” Building that route handler is a separate concern; here you just need to know the same conflict has two honest shapes.

Now the payoff: the React 19 form that turns the conflict Result into a moment where the user sees what happened and chooses, instead of losing work. This is where optimistic concurrency stops being a database technique and becomes a user experience.

You’re building on hooks you already know, so here’s one sentence of reminder for each. useActionState wires a form to a Server Action and hands you back [state, formAction, isPending], where state is the latest Result the action returned. useOptimistic lets you show a provisional value immediately, before the server has confirmed it. The form’s inputs stay uncontrolled (defaultValue, per the forms convention), and the new piece is that the version rides through as a hidden input, so it travels with the form’s FormData without ever being rendered.

The form branches on three outcomes of state:

  • { ok: true }: the write landed. Replace the local view with the returned row, show success, and update the hidden version to the new value the server returned. (You bumped it server-side, so the form must catch up, or the next save will conflict against itself.)
  • { ok: false, error: { code: 'conflict' } }: the write lost the race. Render the conflict banner (“This was changed elsewhere”), show the server’s current values, and offer two choices, described below.
  • { ok: false } with any other code: the ordinary error path you already handle.

The conflict banner offers two choices, and the gap between them is a real product decision:

  • “Use latest and edit again” replaces the form’s values (and the hidden version) with the server’s current, so the user re-applies their change on top of fresh data. This is the safe, default path, and it’s the one you make obvious.
  • “Overwrite anyway” re-fires the action with a force flag that bypasses the precondition, stamping the user’s values over whatever’s there. This is a sharp edge, because it deliberately throws away someone else’s write. Gate it behind a role, or hide it entirely, depending on your product, and never present it as a casual convenience. It’s a deliberate, destructive choice, and it should look like one.

The form above wires only useActionState; it doesn’t add useOptimistic. So the note below applies the moment you layer optimistic UI on top of this same conflict flow. There’s a subtlety here worth getting exactly right, because the loose way people describe it is wrong in a way that will trip you up. You may have heard “useOptimistic rolls back on error.” Hold that phrase at arm’s length, because it implies a mechanism that doesn’t fire the way the course writes actions.

Here’s the accurate model. The optimistic value is visible only for the duration of the pending transition. While the action is in flight, the UI shows the optimistic value. When the action resolves, the transition ends, and React re-renders against whatever the actual source-of-truth state is right now.

Now apply that to a conflict. On a conflict, your action returns { ok: false }; it doesn’t throw. (That’s the course’s return-don’t-throw discipline: expected failures are return values, not exceptions.) Because it returned rather than threw, two things follow. First, the success-path state update never runs, because you only advance the real state on ok: true, so the actual state is unchanged. Second, the transition ends normally, so the optimistic value simply expires, and the UI falls back to the unchanged real value. There is no separate “rollback” step: the optimism just stops being shown, and the real state was never advanced.

This matters because automatic rollback in useOptimistic fires only when the action throws. The course returns Results, so the revert you see is “the optimistic value expired and the real state never moved,” not “React caught an exception and undid something.” It’s the same visible outcome on screen, but a completely different mental model, and the wrong one will have you hunting for a rollback that isn’t happening.

The new thing this lesson adds on top of that: once the optimism expires, the form renders the conflict banner against the user’s still-present (uncontrolled) input plus the server’s current. The user never lost what they typed; they just learned the row moved underneath them, and now they choose.

You already carry the right mental model for this, the optimistic-mutation state machine from earlier in the course: idle → submitting → {success | conflict | error}. The conflict transition is the named state this entire lesson exists to add. Here it is, with the new arrival highlighted:

idle
submit
submitting
success
conflict
error

The mutation state machine you already know. conflict is the state this lesson adds: the second tab’s write was rejected, and the user is shown the current value to decide.

Now the form itself. Step through the wiring: the hidden version input, the useActionState hookup, and the three-way branch on state.

export function EditInvoiceForm({ invoice }: { invoice: Invoice }) {
const [state, formAction] = useActionState(updateInvoice, null);
const row = state?.ok ? state.data : invoice;
return (
<form action={formAction}>
<input type="hidden" name="id" defaultValue={row.id} />
<input type="hidden" name="version" defaultValue={row.version} />
<input name="amount" defaultValue={row.amount} />
{state?.ok === false && state.error.code === 'conflict' && (
<ConflictBanner current={state.current} />
)}
<SubmitButton>Save</SubmitButton>
</form>
);
}

useActionState wires the form to the action and hands back the latest Result as state. It’s the same hook you’ve used, and null is the initial state, before any submit.

export function EditInvoiceForm({ invoice }: { invoice: Invoice }) {
const [state, formAction] = useActionState(updateInvoice, null);
const row = state?.ok ? state.data : invoice;
return (
<form action={formAction}>
<input type="hidden" name="id" defaultValue={row.id} />
<input type="hidden" name="version" defaultValue={row.version} />
<input name="amount" defaultValue={row.amount} />
{state?.ok === false && state.error.code === 'conflict' && (
<ConflictBanner current={state.current} />
)}
<SubmitButton>Save</SubmitButton>
</form>
);
}

The version travels as a hidden input, carried in FormData and never rendered. defaultValue keeps it uncontrolled, per the forms convention. This is how the client’s read-time version reaches the action’s precondition. On a successful save, row becomes the returned row, so this picks up the new version automatically.

export function EditInvoiceForm({ invoice }: { invoice: Invoice }) {
const [state, formAction] = useActionState(updateInvoice, null);
const row = state?.ok ? state.data : invoice;
return (
<form action={formAction}>
<input type="hidden" name="id" defaultValue={row.id} />
<input type="hidden" name="version" defaultValue={row.version} />
<input name="amount" defaultValue={row.amount} />
{state?.ok === false && state.error.code === 'conflict' && (
<ConflictBanner current={state.current} />
)}
<SubmitButton>Save</SubmitButton>
</form>
);
}

The conflict branch: when the Result is ok: false with code: 'conflict', render the banner against the server’s current, the sibling field the conflict helper added. Every other outcome, success or other errors, falls through to the paths you already handle. This one branch is the entire new surface.

1 / 1

That three-way branch on the Result is the one genuinely new piece of code you write in this lesson. Everything else was a one-line schema change or two extra WHERE clauses. So make the branch second nature by wiring it yourself.

Below is a starter form whose action is a stub you can control. It’s React-only: there’s no real Server Action, just a function that returns a mocked Result so you can simulate both outcomes. Your job is to wire the branch so a conflict result renders the banner with the server’s current value, and a success result updates the displayed value.

The save action returns a Result, and the two buttons below let you drive it to either outcome (no server needed). Wire the two branches in the marked spot: (1) when state is a conflict — state?.ok === false && state.error.code === 'conflict' — render a banner with role='alert' showing the server's current amount, state.current.amount; (2) when state is a success — state?.ok — show the saved amount, state.data.amount, in the element with data-testid='saved'. The hidden version input is already wired; leave it.

Preview
    Reference solution
    import { useState } from 'react';
    const conflictResult = {
    ok: false,
    error: {
    code: 'conflict',
    userMessage: 'This invoice was changed in another tab. Refresh to see the latest version.',
    },
    current: { amount: 150 },
    };
    const successResult = (amount) => ({ ok: true, data: { amount } });
    export function App() {
    const [state, setState] = useState(null);
    const [amount, setAmount] = useState(100);
    return (
    <form className="space-y-3" onSubmit={(e) => e.preventDefault()}>
    <input type="hidden" name="version" defaultValue={7} />
    <label className="block">
    Amount
    <input
    name="amount"
    type="number"
    value={amount}
    onChange={(e) => setAmount(Number(e.target.value))}
    className="block border px-2 py-1"
    />
    </label>
    {state?.ok === false && state.error.code === 'conflict' && (
    <p role="alert" className="text-red-600">
    Changed elsewhere — it's now {state.current.amount}.
    </p>
    )}
    <p data-testid="saved">{state?.ok ? `Saved: ${state.data.amount}` : ''}</p>
    <div className="flex gap-2">
    <button type="button" onClick={() => setState(successResult(amount))} className="border px-3 py-1">
    Save
    </button>
    <button type="button" onClick={() => setState(conflictResult)} className="border px-3 py-1">
    Simulate a conflicting save
    </button>
    </div>
    </form>
    );
    }

    The two branches are the whole point. The conflict branch is gated on the discriminant, state?.ok === false && state.error.code === 'conflict', and reads state.current.amount, the fresh server row the conflict helper shipped alongside the error, so the user sees what they’d be overwriting. The success branch is gated on state?.ok and reads state.data.amount. Every other outcome falls through to the paths a real form already handles.

    Now the discipline that separates this from cargo-culting. You’ve just learned a powerful pattern, and the failure mode of learning a powerful pattern is applying it everywhere. A version column on every table, with a 409 possible on every save, is friction, not safety. Most writes don’t need it, and adding it where it isn’t needed makes your app worse.

    Here’s the question to ask yourself: is there a stale client read in the write loop? Optimistic concurrency only earns its place when a client read a value, sat on it, and then wrote based on it, because that’s the only situation where a second writer can overwrite a fresh value. If that read-modify-write-through-a-human loop isn’t there, last-write-wins is correct, and a version column is dead weight. There are three common cases where the loop genuinely isn’t there:

    • Single-user toggles. A personal preference, or a per-user “show archived” flag like the tri-state filter from this chapter’s first lesson. One human owns it, so there’s no second writer to lose a write to. Last-write-wins isn’t just acceptable here; it’s the correct behavior, because the user expects their toggle to win.
    • Append-only writes. A comment, an audit note, or a new status entry. Each write creates a new row; nothing existing gets overwritten, so there’s nothing to lose. Two people commenting at once both succeed, and that’s exactly right.
    • SQL-side increments. SET count = count + 1. The read and the write happen inside the database, in one atomic statement, so no client holds a stale value across think-time. Two concurrent increments both land correctly, because neither one read the count into a form first.

    The senior call, stated plainly: add the version column when there’s a read-modify-write loop through a client, and skip it otherwise. Over-applying it adds friction on every save. Under-applying it causes silent data loss. The skill is in the judgment, not the mechanism.

    To make that judgment repeatable, walk the decision below. It forces the questions in the order an experienced engineer actually asks them: read loop first, then realistic concurrency, then whether a stale read can actually overwrite a fresh write.

    Should this write carry a version column?

    One more distinction to keep straight, because you’ll meet its neighbor soon and the two are easy to confuse. A version precondition is not the same thing as an idempotency key, and they protect against different bugs:

    • An idempotency key stops the same write from landing twice, such as a double-click or a network retry that re-sends an identical request. Same intent, fired twice, should happen once.
    • A version precondition stops two different writes from overwriting each other: two tabs, two distinct edits, racing. Different intents, both should be acknowledged, but the loser must be told it lost.

    They’re orthogonal, and a single action can want both: the idempotency key dedupes Bob’s accidental double-submit, while the version check catches that Bob and Alice were editing the same row. You’ll go deep on idempotency keys later in the course, when you build webhook ingestion. For now, just keep the two filed separately. “Don’t run my write twice” and “don’t let my write erase someone else’s” are different problems with different fixes.

    And one boundary, named once so you don’t go looking for it here: this is not collaborative real-time editing. Tools like Google Docs, where two cursors edit the same paragraph and both sets of keystrokes survive and merge, use a different family of techniques entirely (CRDTs, operational transforms). That’s a much heavier problem, and it’s out of scope for this course. Optimistic concurrency answers a narrower, far more common question, “did this whole-form save lose a race?”, and for the vast majority of SaaS editing, that’s exactly the question you have.

    You’ve now seen every piece in isolation. Here they are assembled into the reference shape the rest of the course’s edit surfaces mirror: the same invoices table you’ve carried through this chapter, in the same action home. The deep walkthroughs already happened above, so these tabs are the consolidated artifact and are only lightly annotated. Read them as the whole thing, finally in one place.

    export const invoices = pgTable('invoices', {
    id: uuid().primaryKey().$defaultFn(() => uuidv7()),
    orgId: uuid().notNull(),
    amount: numeric({ precision: 12, scale: 2 }).notNull(),
    version: integer().notNull().default(1),
    ...lifecycleColumns,
    });

    The one schema change. A single version column on the existing table; DEFAULT 1 starts new rows at 1 and backfills every existing row.

    That’s the model. A user-editable row that two tabs can realistically open gets a version column at schema-design time. The precondition rides in the same WHERE as tenancy and lifecycle. Zero rows affected is a 409, never a quiet success. And the user sees the current value and decides: refresh and reapply, the safe default, or overwrite, the deliberate sharp edge. The inverse matters just as much: no client read in the write loop means no version column.

    The primary sources behind this lesson, covering the concept, the React hooks, and the HTTP error standard, are worth keeping close: