[Guide] Build a Recipe Manager - implementation notes
The Recipe Manager guide has a very specific posture: do optimistic mutation into a route document, post the write, and keep rendering with duplicate reads instead of pausing the UI to "refresh". It also treats shopping output as a first-class derived surface (counts, normalized lines, preview text, editor health), so the app is always ready to show *something* coherent even while the network is catching up.
If you tried mutation-without-invalidation, what did you render as evidence that the UI is "ahead" of the server? Do you store derived shopping lines as a single normalized structure, or keep multiple derived views (per recipe vs bundle)? How do you keep the editor from drifting when the detail route does fresh reads while you're mid-edit? Did you use draftKey remounts for re-baselining, or did you solve it with explicit reset actions?
Comments (20)
Back to latestWe adopted the "keep moving" posture and it made the app feel faster even when the network wasn't.
The key was admitting (in the UI) when we were optimistic.
Concrete evidence: we rendered data-mutation-lane="optimistic|confirmed|reconciling" on the route shell.
It sounds silly, but it instantly explained screenshots.
Normalized shopping lines were worth storing because three panels cared about the same thing (list, preview text, "copy to clipboard"). We kept one canonical derived shape and derived presentation from it:
ts
type Line = { key: string; label: string; qty: number; unit: string };
function deriveShoppingLines(doc: any) {
const recipe = doc.read('draft');
const lines = normalizeIngredients(recipe.ingredients);
doc.write('shoppingLines', lines);
doc.write('shoppingText', lines.map((l: Line) => `${l.qty} ${l.unit} ${l.label}`).join('\n'));
}
If the text is stored, the UI can show it instantly and it doesn't drift between panels.
Counterpoint: duplicate reads can get you into "last write wins" surprises.
We had a bug where the optimistic doc wrote a title, then a fresh read re-applied an older title, then the mutation confirmed and flipped again.
Yep, reconciliation is the hard part. We ended up storing an optimisticRevision and rendering it as evidence, then only applying server reads if they were >= that revision.
Not perfect, but it stopped the flicker and made the "why did it change" question answerable.
We used draftKey remounting for one thing only: "start a new recipe based on this recipe".
For everything else we did explicit resets so we could keep field focus stable.
The guide's editor health score is underrated.
Even if it's a fake number, it forces you to define what "healthy" means (no empty ingredient lines, step count nonzero, title present, etc.).
We implemented the health score as a derived key and used it to gate mutation attempts. It made validation feel less like random red errors and more like a continuous signal.
ts
function deriveEditorHealth(doc: any) {
const d = doc.read('draft');
let score = 100;
if (!d.title.trim()) score -= 30;
if (d.ingredients.filter((x: string) => x.trim()).length === 0) score -= 30;
if (d.steps.filter((x: string) => x.trim()).length === 0) score -= 20;
doc.write('editorHealth', Math.max(0, score));
doc.write('isValid', score >= 60);
}I struggled with the "mutation without invalidation" phrasing until I read it as: don't pause the UI just to satisfy a cache ritual.
It's closer to: "render what you know, then let reads converge".
We kept per-recipe derived shopping output and a separate bundle derived output.
The trick was making the bundle explicitly its own derived key so you don't accidentally mix them.
For drift: we separated the server read (recipe) from the editor draft (draft) and never overwrote draft from reads unless the user explicitly "rebase"d.
Otherwise duplicate reads are constantly fighting the editor.
We also rendered an evidence line like: serverRev=12 optimisticRev=14.
People complained at first, then they stopped filing impossible bugs.
The guide keeps mentioning "duplicate reads" and it maps to the docs posture pretty well.
Related: Data Fetching and Caching (Best Practices) and Testing and Debugging.
Short comment: storing preview is the only way I can get consistent UX in editors.
If preview is derived live in render, it ends up slightly different across panels, which reads as "bug" to users.
Has anyone actually shipped "no invalidation" with offline mode?
I feel like offline makes the optimistic lane the primary lane and reconciliation becomes permanent.
We did, but we treated reconciliation as a first-class UI story (queued writes, conflict banner, "fork a copy" action).
The guide posture still works, you just have to admit that "confirmed" might be delayed for hours.
The best part for me was that the shopping list view isn't a separate app; it's a derived view of the same document truth.
Once you store normalized lines, everything else is just rendering from that contract.
Counterpoint to storing lots of derived stuff: you can also store just shoppingLines and compute text from it.
We didn't store shoppingText because it was easy to derive and we never had sibling drift there.
That seems reasonable. We stored text only because we had three separate "copy" surfaces and they were diverging.
If you only have one, live derivation is probably fine.
One implementation note: do not let ingredient parsing happen in an effect after render.
If the derived keys exist to make render correct, derive them in the mutation path or in a render-visible helper.
I wish the guide emphasized the "single call site" idea more: mutate, then derive, then render from derived keys.
That's what kept us from forgetting to update ingredientCount and stepCount in random places.
The guide reads like an argument that caches should be a preference, not an authority. I'm into it.
Late to the thread, but the posture worked best when we treated "freshness" as a product decision.
Sometimes being consistent is more valuable than being newest.