Every admin write on the site does three things, in order: it mutates the database, it writes a structured audit line, and (on the UI side) it commits an optimistic state update before the network resolves. Each of those was its own copy-paste mess for a while: a handful of handlers each doing roughly the same thing, with subtle drift on the parts that mattered. This post is about the small toolkit they collapsed into.
This is a layer further inside than the validation post. By the time we get here, the body has been parsed, the user is authenticated, and we have typed data in hand. The interesting work is what the handler does with it.
Failed admin writes log inside the handler (the try/catch around the write does that for you). Successful ones don't, unless you make them. Without an explicit audit line, the access log can't answer "did someone create a post at 3am"; the 200 looks like every other 200.
So every successful admin handler ends with a call to auditLog:
// src/lib/auditLog.ts
export const ADMIN_AUDIT_TAGS = [ // ❶
"[api:admin:posts:POST]",
"[api:admin:posts:PUT]",
"[api:admin:posts:DELETE]",
"[api:admin:posts:BULK]",
"[api:admin:projects:POST]",
"[api:admin:projects:PUT]",
"[api:admin:projects:DELETE]",
] as const
export type AdminAuditTag = (typeof ADMIN_AUDIT_TAGS)[number] // ❷
export interface AdminAuditPayload {
id: number
slug: string | null
section: Section | null
sortOrder: number | null
previousSection: Section | null
previousSlug: string | null
}
export function auditLog(tag: AdminAuditTag, payload: AdminAuditPayload): void {
console.info(`${tag} success`, payload)
}The output is one console.info line per successful write, prefixed with the route tag and carrying a typed payload. Grep-able by tag, id, slug, or section.
Two design choices keep this from drifting. First, the tag list is a const array ❶: adding a new admin resource is one edit, and the runtime list of valid tags is greppable from the array itself, not scattered across call sites. The type derived from it ❷ is what makes the discipline stick: auditLog("[api:admin:psots:POST]", …) fails at tsc, so a typo'd tag can't quietly emit a malformed line and break every log-aggregator grep set up against the real tag.
Second, the payload's fields are all T | null, never T | undefined or optional ?. At a call site:
auditLog("[api:admin:posts:POST]", {
id: post.id,
slug: post.slug,
section: post.section,
sortOrder: null,
previousSection: null,
previousSlug: null,
})The verbosity is the feature. A handler that fails to populate a field has to think about it and pass null explicitly. Leaving it optional meant "I forgot" and "this field doesn't apply" produced the same output, which is exactly the case where silent regressions hide. A future me adding tags to the Post model has to pick: "is the tag list interesting enough to audit?" The T | null discipline forces that question instead of letting it slide.
The one payload field that doesn't fall out of the type is previousSlug. Renames are the one mutation that breaks log-grep: if hello-world becomes the-shape-of-hello-world, a future search for the old name finds nothing because slug in the audit line is now the new value. previousSlug closes that gap. On a PUT that changes the slug, the audit line carries both names; on every other mutation, previousSlug is null.
The two handlers that emit previousSlug (posts PUT and projects PUT) compute it the same way: re-read the row inside the transaction, snapshot before the update. Without that unification, post-renames and project-renames would have logged different shapes and the aggregator couldn't grep "what was the previous slug of post X" cleanly.
Two admin widgets save optimistically: the "Featured" toggle on each post (a checkbox that pins or unpins it from the top of the section) and the sort-order input on each project (a number that decides display position on the projects page). Both live inline in the list view, not behind an edit page. The UI flips immediately when you click; the network catches up. If the request fails, the UI reverts. If a newer mutate starts mid-flight, the older one becomes irrelevant; its result should be quietly discarded rather than overwriting the newer commit.
I extracted this into a hook at the second consumer, when the second copy of the boilerplate showed up:
// src/lib/useOptimisticMutation.ts
export function useOptimisticMutation<TPayload>({
url,
method = "PUT",
}: Config) {
const abortRef = useRef<AbortController | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
return () => {
abortRef.current?.abort()
abortRef.current = null // ❶
}
}, [])
async function mutate(payload, { onRevert, errorFallback = "Failed to save" }) {
// ...
}
return { mutate, isSaving, error }
}The API is mutate(payload, { onRevert }) plus the isSaving and error state for the UI. Internally, each mutate creates an AbortController (the standard API for cancelling an in-flight fetch), passes its signal into the request, and calls abort() on the previous controller if one is still in flight. The aborted fetch rejects with an AbortError, which the catch block recognizes and silently drops. That's how supersession works.
The non-obvious bit is ❶: the unmount cleanup nulls the ref as well as aborting. That's not symmetry-for-its-own-sake; it's a real bug fix. In the narrow window where fetch resolves before the abort signal is observed — short buffered bodies, or a test mock that ignores signals — the non-ok branch's "is this controller still the latest?" guard would otherwise read the old controller and try to setState on an unmounted component. Nulling the ref makes the guard correct for every post-unmount continuation, not just the abort-rejection path.
I didn't think of that one. It came out of a test I'd written assuming the implementation was already right:
it("does not call setState after unmount", async () => { /* … */ })The test passed once, failed once on a flake, and the flake turned out to be the race above. The lesson I want to remember: a test premise that "should pass with the current implementation" sometimes uncovers an actual implementation gap, not a bad test.
mutate returns more than a success/failure boolean. Failure has two flavors that the caller treats differently: a real failure (the server returned non-ok or fetch threw) and a supersession (a newer call started, or the component unmounted mid-request). A discriminated union splits them:
export type MutateResult =
| { ok: true }
| { ok: false; reason: "failure" | "superseded" }"failure" means the server returned non-ok or fetch threw. The hook has already called onRevert and set the error message. The caller can show a toast, log, refresh.
"superseded" means a newer mutate started before this one resolved, or the component unmounted mid-request. The hook deliberately does not call onRevert and does not set the error; the newer call owns the outcome. The caller's job is to do nothing: no toast, no router.refresh(), no state churn.
Today's two callers (IsFeaturedToggle and ProjectSortOrderInput) collapse the two reasons together: both treat any !ok as "stop." The discriminant lands now so a future consumer that wants to skip a post-mutate router.refresh() specifically on supersession doesn't pay the extraction tax twice.
At the call site, here's IsFeaturedToggle's change handler:
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const initialIsFeatured = isFeatured
const next = e.target.checked
setIsFeatured(next) // ❶
const { ok } = await mutate(
{ isFeatured: next },
{ onRevert: () => setIsFeatured(initialIsFeatured) } // ❷
)
if (ok) {
router.refresh() // ❸
}
}Three lines that map to the contract: commit the optimistic value ❶, describe how to revert ❷, do success-path side effects only when ok is true ❸. About thirty lines of plumbing per widget collapsed to this.
Inside mutate's catch block, the two failure flavors get routed by isAbortError:
} catch (err) {
if (isAbortError(err)) {
return { ok: false, reason: "superseded" }
}
onRevert()
setError(errorFallback)
return { ok: false, reason: "failure" }
}An abort means a newer call started and rejected this fetch: silently drop, return "superseded". Anything else (network down, server error, malformed response) is a real failure: revert, set the error, return "failure".
The helper itself:
export function isAbortError(err: unknown): boolean {
if (err instanceof DOMException && err.name === "AbortError") {
return true
}
if (err instanceof Error && err.name === "AbortError") {
return true
}
return false
}Two branches because different environments surface aborts differently: browsers throw a DOMException; happy-dom in the test runner throws a plain Error with name === "AbortError".
Centralizing this matters for two reasons. Five admin call sites used to spell it out by hand with subtly different shapes — one was checking err.code, one was duck-typing .aborted — and the test-env behavior had diverged from production on at least one site without anyone noticing. And a future runtime switch (Bun's fetch, Node's native fetch, an HTTP client change) cannot silently turn an abort that should be swallowed into one that surfaces as an error toast.
The next post is about the content side of the app: 200 markdown posts that need to render fast, drafts that need to stay invisible, and one feature ("schedule this post to publish in three days") that cascades into more places than you'd expect, including a findUnique → findFirst migration I didn't see coming.
If your optimistic-mutation hook turned out cleaner than mine, I'd genuinely like to read it: @roland.leth.ro.