Every week I build the same list. Milk, bread, eggs, the usual orbit of things a household actually runs on — retyped or re-dictated every single Sunday. The fix is obvious and every shopping app has it: "Weekly essentials," saved once, spun up fresh in one tap.
What's less obvious is what that feature costs. I went in braced for a Template model, template-item rows, copy logic, maybe a versioning question. The whole thing — save any list as a template, browse templates, instantiate one, delete one — shipped as one new schema field and zero new tables. The design work wasn't building a template system. It was realising there shouldn't be one.
TL;DR — A reusable template is the same
ShoppingListmodel withisTemplate: a.boolean()on it. Kind is orthogonal to lifecycle, so it gets its own field instead of a new model or a newstatusvalue. One copy helper runs in both directions (list → template, template → list). The real engineering wasn't the flag — it was the three ripple effects: every piece of code that enumerates lists had to learn what the flag means, or quietly break.
(Part 11 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The fork: a new model, a new status, or a flag
The app already has a ShoppingList model with a lifecycle enum — ACTIVE, ARCHIVED, COMPLETED — and ShoppingItem rows hanging off it. Three ways to represent "template":
A Template model. A new table, new CRUD, new copy logic between two shapes that are 95% identical. Every future list feature (soft delete, per-user scoping, offline sync) gets implemented twice or templates fall behind. Rejected fast.
A "TEMPLATE" status. Tempting — the enum is right there. But status answers "where is this list in its life?", and template-ness answers "what kind of thing is this?" Jam them into one field and you get incoherent states by construction: a template can never be archived (the slot is occupied), and every status === "ACTIVE" check in the app silently needs a special case it doesn't know it needs.
A boolean. Kind is orthogonal to lifecycle, so it gets an orthogonal field:
ShoppingList: a.model({
userId: a.string().required(),
name: a.string().required(),
status: a.enum(["ACTIVE", "ARCHIVED", "COMPLETED"]),
isTemplate: a.boolean(), // true = reusable template, hidden from the switcher
items: a.hasMany("ShoppingItem", "listId"),
// … soft-delete trio
}),
That's the entire backend diff — one line. And because it's an additive field, DynamoDB just… accepts it. No table replacement, no migration. (This matters more than it sounds: in Part 4 I learned the hard way which schema changes CloudFormation treats as "replace the table." Additive columns are the safe kind. I check this before every schema change now.)
The commit for the whole feature: 8 files, +268/−28. One of those 268 lines is schema; the rest is client.
A template is storage, not machinery
Because a template is a ShoppingList, its items are ordinary ShoppingItem rows. Everything the app already does to lists happens to templates for free: per-user scoping, the soft-delete convention, the typed client queries. There is no "template subsystem" to maintain — there's one flag and a copy helper.
The copy helper is the only genuinely new logic, and it runs in both directions:
// Copy a list's live items into another list, reset to unchecked with fresh
// positions. Shared by save-as-template and instantiate-template.
async function copyItems(sourceListId: string, targetListId: string, userId: string) {
const { data } = await client.models.ShoppingItem.list({
filter: { and: [{ listId: { eq: sourceListId } }, NOT_DELETED_FILTER] },
});
const ordered = [...data].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
for (let i = 0; i < ordered.length; i++) {
const it = ordered[i];
await client.models.ShoppingItem.create({
listId: targetListId,
userId,
name: it.name,
quantity: it.quantity ?? 1,
unit: it.unit ?? undefined,
category: it.category ?? undefined,
note: it.note ?? undefined,
checked: false,
position: i,
sourceText: it.sourceText ?? it.name,
});
}
}
Save-as-template snapshots the live list into a flagged list; "Use" copies a flagged list out into a fresh active one. Same loop, opposite directions (excerpt — error handling elided):
const saveAsTemplate = useMutation({
mutationFn: async ({ sourceListId, name }) => {
const { data: tpl } = await client.models.ShoppingList.create({
userId, name, status: "ACTIVE", isTemplate: true,
});
await copyItems(sourceListId, tpl.id, userId);
return tpl;
},
});
const instantiateTemplate = useMutation({
mutationFn: async ({ templateId, name }) => {
const { data: list } = await client.models.ShoppingList.create({
userId, name, status: "ACTIVE", isTemplate: false,
});
await copyItems(templateId, list.id, userId);
return list;
},
});
One honest divergence from my own plan: the plan said save-as-template should copy only the unchecked items ("what's still outstanding"). The shipped version copies everything, reset to unchecked — because halfway through building it I realised a template is a set, not a progress snapshot. If milk was already ticked off when you hit save, you still want milk in "Weekly essentials." The plan survives contact with the copy loop about as well as plans usually do.
One flag, three ripples
Here's the part that makes "just add a boolean" worth a blog post: every piece of code that enumerates lists now has an opinion about templates, whether it knows it or not. I found three.
1. The switcher must partition. Templates live in the same table as real lists, so the list-switcher sheet would happily show them. The split is client-side, exactly like the existing archived partition:
const { data: allLists, isLoading } = useShoppingLists();
// Templates live alongside real lists but never appear in the switcher.
const lists = useMemo(() => (allLists ?? []).filter((l) => !l.isTemplate), [allLists]);
const templates = useMemo(() => (allLists ?? []).filter((l) => !!l.isTemplate), [allLists]);
The sheet renders them as their own section — each with a Use button and a delete — below Archived:
2. The "first list" guard must mean real lists. On first run the app auto-creates a default list when the user has none. That guard used to ask "are there zero lists?" — which is now the wrong question. A user whose only surviving list is a template has rows in the table but has no list to shop with; the old check would skip auto-create and strand them staring at a template they can't open. The guard (and the loading state) now counts only non-template lists.
3. The frequency counter must exclude template items. Last post built "frequently bought" chips by counting the user's item history client-side. A template's items are a saved set, not a shopping occasion — and worse, instantiating a template copies its rows, so if template rows counted, every "Use" would double-count the entire weekly shop, forever, compounding weekly. The scan now drops any item whose list is flagged.
None of these three is hard. All three are the actual price of the feature — and exactly the kind of thing that doesn't appear in the "just add a boolean" estimate.
Names, dates, and deletes
Two small decisions I'd flagged as open questions in the plan, resolved by picking the lowest-friction option:
Instantiate naming is zero-prompt. Tapping Use doesn't ask you to name anything — it stamps the template name with today's date and switches you to the new list:
instantiateTemplate.mutate(
{ templateId: tpl.id, name: `${tpl.name} · ${shortDate()}` }, // "Weekly essentials · 22 Jul"
{ onSuccess: (list) => { if (list) onSelect(list.id); } },
);
A naming prompt is exactly the friction this feature exists to remove. You can rename later; you almost never will.
Template delete is a soft delete. Like every "delete" in this app, removing a template flips _deleted flags — never a real .delete(). Which, as the last post found out, occasionally pays unexpected dividends: history you didn't destroy is history you can mine later.
Honest limits, and the phase 2 I'm not building
copyItems is a sequential create loop, not a transaction. Kill the app mid-copy and you get a partial template (or a partial instantiation). At personal scale — a dozen items, sub-second copies, a foreground action you're watching — that's an acceptable MVP tradeoff. It's written down as one, next to the code, so future-me doesn't mistake it for a decision that was never made.
And the obvious next step is deliberately not built: scheduled recurrence. "Every Monday, create this week's list from the template and ping me" needs real machinery — a cadence field or a small Schedule model, an EventBridge-scheduled Lambda that instantiates due templates server-side, and a push notification when the list is ready. That's a separate project with a backend footprint, and it only makes sense once manual reuse proves people (well, I) actually reach for templates. Same discipline as last post's exit ramp: design it, write it down, don't build it yet.
What I took away
- Model kind orthogonally to lifecycle. A boolean beside a status enum beats a new enum value that poisons every existing check — and beats a parallel model that duplicates your whole feature surface.
- Additive schema fields are the cheap kind. Know which changes replace your DynamoDB table before you make them, not after.
-
A flag is never just a flag. Budget for every place that enumerates the model: the switcher, the bootstrap guard, the aggregation — each needed to learn what
isTemplatemeans. - Ship manual before scheduled. Reuse-on-demand is one field and a copy loop; recurrence is a Lambda, a scheduler, and notifications. Prove the habit before you automate it.
Next up
This feature closes out the "big pieces" of the shopping tab — which means it's time for the polish montage: typing "2 milk" and getting Milk ×2, merge-instead-of-duplicate, clear-completed, brand pull-to-refresh, sticky section headers. A dozen small touches, none worth a post, all worth shipping. That's Part 12.
Where do you draw the line between "flag on an existing model" and "new model"? I keep choosing the flag and it keeps working — tell me about the time it didn't.


Top comments (0)