Skip to content

docs: fix the optimistic() reducer in the no-boilerplate blog post #1258

Description

@vivek7405

Problem

blog/optimistic-ui-without-boilerplate.md is a fifth copy of the optimistic() declarative snippet, and it is the last one still teaching the shape #1203 removed everywhere else.

At L68-72 its reducer mints the row inline with a hardcoded temp id:

this.optimisticTodos = optimistic(this, {
  source: () => this.todos,
  update: (state, title) => [
    ...state,
    { id: 'tmp', title, completed: false, pending: true },
  ],
});

and L81 calls this.optimisticTodos.add(title, promise) with the bare-string payload.

optimistic()'s reducer runs on EVERY .value read, not once per .add() (packages/core/src/optimistic.js get value() re-folds the whole queue from source()). A hardcoded 'tmp' is stable per read, so it has no per-render churn, but two adds in flight both produce a row with id: 'tmp'. That breaks any consumer treating the id as unique: a repeat(todos, t => t.id, ...) keyed list, a @click handler capturing the id, an aria-activedescendant, a test selector. Queue stacking and per-entry release are NOT affected (add() keys its entry on an internal opt-${n}), so the damage is strictly downstream of rendering.

#1203 converged the four canonical copies on the payload-carried temp id: AGENTS.md, .agents/skills/webjs/references/optimistic-ui.md, packages/core/src/optimistic.js's JSDoc, and website/app/docs/client-router/page.ts. All four now read id: add.tempId. This post was scoped out of that change because it is published prose rather than a reference doc, and because the fix is not confined to the snippet (see below).

Found during the review of #1241.

Implementation plan

Decision: Port #1241's canonical form into the post in its untyped JavaScript variant, the one website/app/docs/client-router/page.ts L71-84 and packages/core/src/optimistic.js L77-90 carry, and copy that four-line reducer comment across verbatim. The post's snippets are untyped JS throughout (prop(Array), no annotated parameters), so the TypeScript-annotated AGENTS.md / skill variant would be the only typed line in the post; the untyped canonical variant is byte-identical in every load-bearing line and reads native here. On the editorial half, narrow the payoff claim rather than drop it: #1241 already settled the house phrasing when it rewrote the same beat in AGENTS.md and the skill reference to "no reconciling a temp id against the real one". What optimistic() removes is the temp-id BOOKKEEPING (the previous-state cache, the try-catch, mapping the temp row back to the server row), not the id, which is one line in the handler. The post keeps its strongest line and stops contradicting the code directly above it.

Rejected:

  • Dropping the "No temp IDs" beat entirely: throws away the post's payoff sentence when a truthful narrower version already exists in the codebase.
  • Copying the TypeScript-annotated AGENTS.md / skill snippet verbatim: would be the only annotated code in an untyped post.
  • Adding createdAt: new Date() to match AGENTS.md: the post's row shape never carried it, and it is the one impure line docs: make the canonical optimistic() reducer snippets pure #1241 tolerated with a caveat, not a thing to propagate to a fifth copy.
  • Leaving the snippet and adding a purity warning only: docs: make the canonical optimistic() reducer snippets pure #1241 rejected this exact option for the reason docs: teach app agents to derive types, never unknown or any #1200 established, an agent copies the example far more reliably than it follows the prose. The purity note is added as well, not instead.
  • Rewriting the six-step "old way" walkthrough at L20-55: its temp id is correct there and the section is the setup the payoff pays off.
  • Re-editing the four canonical copies: they are correct as of docs: make the canonical optimistic() reducer snippets pure #1241 and are the source, not the target.
  • Adding a test that pins the markdown snippet: no such test exists for any of the five copies, and packages/core/test/signals/optimistic-declarative.test.js says so in its own header comment. Pinning prose text is not the layer this belongs at.

Steps

All edits are in blog/optimistic-ui-without-boilerplate.md. Nothing else in the repo changes.

1. Front matter, L5 description. The description is the SEO meta and repeats the claim. Replace no temporary ID bookkeeping with no temp-id reconciliation, so the line reads:

description: "WebJs now ships a declarative optimistic() API with full React 19 useOptimistic parity for Web Components. No try-catch, no manual state caching, no temp-id reconciliation. Just add, await, and reconcile."

2. The main snippet, the fenced block at L63-93. Replace the whole block with this. The reducer comment is copied verbatim from website/app/docs/client-router/page.ts L73-76.

class TodoList extends WebComponent({ todos: prop(Array) }) {
  constructor() {
    super();
    this.todos = [];
    this.optimisticTodos = optimistic(this, {
      source: () => this.todos,
      // Pure: the row is derived from the payload, nothing is minted here.
      // The .value getter re-folds the queue on EVERY read, so a minted id
      // would differ per render, and a hardcoded 'tmp' would collide across
      // two concurrent adds. The temp id is minted once in the handler.
      update: (state, add) => [
        ...state,
        { id: add.tempId, title: add.title, completed: false, pending: true },
      ],
    });
  }

  async handleSubmit(e) {
    e.preventDefault();
    const title = getTitle(e);
    const tempId = crypto.randomUUID();
    const promise = createTodo({ title });
    this.optimisticTodos.add({ tempId, title }, promise);
    const result = await promise;
    if (result.success && result.data) {
      this.todos = [...this.todos, result.data];
    }
  }

  render() {
    return html`<ul>${this.optimisticTodos.value.map(t =>
      html`<li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;
  }
}

3. The paragraph at L95, which still describes the bare-string payload twice. Replace the whole paragraph with:

The difference is structural. Instead of manually caching and reverting, you declare a pure reducer that builds the optimistic row out of a payload, then call .add({ tempId, title }, promise) and let the wrapper handle the rest. The optimistic update appears immediately. When the promise settles, it disappears. You update this.todos on success and the source of truth reconciles naturally.

4. The payoff line at L97. Replace the whole paragraph with:

No try-catch. No previous-state cache. No reconciling a temp id against the real one. Minting the id is a single line in the handler, and everything that used to surround it is gone: nothing tracks the temp row afterwards, because the overlay drops whole when the promise settles and the authoritative row arrives from result.data. The reducer is the only place the optimistic shape lives, so there is one source of truth for what an optimistic item looks like.

5. The .value paragraph at L103, in "How it works under the hood". It already explains that the value is computed on every read, which is exactly where the purity rule belongs. Append these two sentences to the end of that paragraph:

Because the fold re-runs on every read, the reducer has to be pure. Anything it mints is minted again on each render, so a crypto.randomUUID() inside update would hand the pending row a fresh id every time and a keyed list would tear that row down and rebuild it on each update, losing focus and any in-progress transition. Mint the id once in the handler, pass it in the payload, and it stays stable for the life of the row.

6. The auto-release snippet at L131-135. Replace with:

const tempId = crypto.randomUUID();
const promise = createTodo({ title });
this.optimisticTodos.add({ tempId, title }, promise);
// The optimistic item disappears when promise settles (resolve or reject).

7. The manual-release snippet at L139-147, which the original issue notes missed. Its .add(title) is the same bare-string payload. Replace the first line only, leaving the try/finally body untouched:

const release = this.optimisticTodos.add({ tempId: crypto.randomUUID(), title });

The prose at L137 already says .add(payload), so it needs no change.

8. L174, "The new API reduces it to three lines: declare the reducer, call .add(), reconcile on success." With the mint in the handler it is no longer three lines, but it is still three steps, and the three named steps are what the sentence is actually counting. Change reduces it to three lines to reduces it to three steps, leaving the rest of the sentence as is.

9. The closing sentence at L176. Replace The boilerplate of temp IDs, try-catch, and manual reconciliation is gone. with The boilerplate of state caching, try-catch, and temp-id reconciliation is gone.

10. Leave these alone, each checked and each correct as written:

  • L12, "you generate temporary IDs", describes the manual pattern.
  • L20-55, the six-step old-way walkthrough, including its tmp-${crypto.randomUUID()}.
  • L164 and L166, the honest-trade section.
  • L172, "The old pattern required understanding temp IDs, state caching, try-catch reconciliation, and concurrent update handling", which is a statement about the OLD pattern and stays true.

Tests

  • N/A, because this is a prose and example change with no behaviour change. packages/core/test/signals/optimistic-declarative.test.js L194-260 already pins every runtime fact the corrected snippet leans on: .value is stable across repeated reads for one queued update, a minting reducer yields a different id per read (the bug, encoded deliberately), and two concurrent adds get distinct payload-derived ids. docs: make the canonical optimistic() reducer snippets pure #1241 added those and proved the counterfactual there.
  • No test pins any doc snippet. That file's own header comment states it: "the doc snippets themselves have no test coverage, so a doc revert would not red these." That is true of all five copies, and it is worth knowing rather than working around.
  • Gate instead of a test. Render the post through the real page module and confirm it still renders: call the default export of website/app/blog/[slug]/page.ts as BlogPost({ params: { slug: 'optimistic-ui-without-boilerplate' } }) through renderToString from @webjsdev/core/server, run from website/ so the #app/... alias resolves (the pattern website/test/ssr/nav-and-routes.test.ts uses), and assert non-empty output containing add.tempId. Throwaway script, not a committed test.
  • Counterfactual: none, and none is owed. Reverting the fix alone reds nothing, because no test reads the markdown. The nearest existing red is the minting-reducer test above, which pins the runtime fact the doc describes, not the doc.

Doc surfaces

Full grep over the repo for optimisticTodos.add, add(title, and tempId, excluding node_modules:

  • Target, the only file that changes: blog/optimistic-ui-without-boilerplate.md.
  • Already correct as of docs: make the canonical optimistic() reducer snippets pure #1241, MUST NOT be re-edited: AGENTS.md L384-403, .agents/skills/webjs/references/optimistic-ui.md L38-58, packages/core/src/optimistic.js L79-90 (JSDoc), website/app/docs/client-router/page.ts L71-84.
  • Already correct before docs: make the canonical optimistic() reducer snippets pure #1241, the reference the others were copied from: packages/cli/templates/gallery/modules/todo/components/todo-app.ts L23-52. Its reducer is the pure discriminated-union form, { id: op.tempId, title: op.title, ... }, with crypto.randomUUID() minted at L50 in the handler. Verified against the current checkout, the gallery is NOT stale and is out of scope.
  • The post is rendered from the markdown by website/app/blog/[slug]/page.ts, so there is no separate page, and no scaffold, MCP, editor-plugin, README, or changelog surface carries this snippet.

Implementation notes (for the implementing agent)

Where to edit: blog/optimistic-ui-without-boilerplate.md only. Line anchors below are verified against the current checkout at 928e409c and supersede the ones this issue originally carried, which were off by a line or two and missed three sites.

  • L5, the front-matter description.
  • L63-93, the fenced WebJs snippet. The optimistic() call is L68-74 and the update reducer is L70-73. The .add(title, promise) is L81.
  • L95, the paragraph explaining the reducer, which describes the bare-string payload twice ("when a title comes in, append an optimistic item", "you call .add(title, promise)").
  • L97, the "No temp IDs" payoff line.
  • L103, the .value paragraph in "How it works under the hood", where the purity note lands.
  • L131-135, the auto-release snippet, with .add(title, promise) at L133.
  • L139-147, the manual-release snippet, with .add(title) at L140. Not in the original notes.
  • L174, "reduces it to three lines". Not in the original notes.
  • L176, the closing paragraph.

Reference implementations, copy from, do not change: packages/cli/templates/gallery/modules/todo/components/todo-app.ts L23-52 for the runnable shape, and website/app/docs/client-router/page.ts L71-84 for the untyped snippet plus the exact reducer comment this plan reuses.

Landmines:

  • Invoke webjs-blog-write before touching the prose. It carries the house voice and the hard rules for this surface: no em-dashes, no internal PR or issue numbers in published text, no process tells. A straight technical edit will violate them.
  • Do not renumber or restructure the post's six-step "old way" walkthrough at L20-55. That section deliberately shows the painful manual pattern, temp ids included, and it is the setup the payoff pays off. The temp id THERE is correct and should stay; only the WebJs-side snippet and the claims about it change.
  • Invariant 11 applies to every added line: no em-dash, no space-surrounded hyphen or semicolon as a pause, WebJs capitalized in prose. Enforced by .claude/hooks/block-prose-punctuation.sh on new content.
  • Invariant 9 does not bite here. The docs page had to escape its backticks because the snippet lives inside an html template literal; this is a markdown fence, so the backticks in html`...` stay as written.
  • The post is rendered from the markdown by website/app/blog/[slug]/page.ts, so there is no separate page to edit.

Invariants to respect: AGENTS.md invariant 11. No behaviour change, so no webjs check rule is involved.

Acceptance criteria

  • The post's reducer no longer hardcodes { id: 'tmp' }; the temp id is minted in the handler and passed in the payload
  • Its snippet matches the untyped canonical form in website/app/docs/client-router/page.ts, reducer comment included
  • All three .add() call sites carry the payload form: L81, L133, and L140
  • The prose explaining .add(payload, promise) moves with the payload change, so no sentence still describes the bare-string payload
  • The "No temp IDs" claim is narrowed to reconciliation, matching the phrasing docs: make the canonical optimistic() reducer snippets pure #1241 landed in AGENTS.md and the skill reference, so the post does not contradict the code it recommends
  • The front-matter description no longer claims temp-id bookkeeping is gone
  • The purity rule appears in the "How it works under the hood" section, attached to the paragraph that explains the per-read fold
  • The six-step "old way" section is unchanged, and so are L12, L164, and L172
  • No file outside blog/optimistic-ui-without-boilerplate.md is touched
  • Prose passes the banned-glyph rules and reads in the post's existing voice
  • /blog/optimistic-ui-without-boilerplate still renders, non-empty, with the corrected snippet in the output

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentation

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions