<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ibukun Demehin</title>
    <description>The latest articles on DEV Community by Ibukun Demehin (@hokagedemehin).</description>
    <link>https://dev.to/hokagedemehin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F464324%2F53d2e251-ca8a-429c-a839-1332cb4f813e.jpeg</url>
      <title>DEV Community: Ibukun Demehin</title>
      <link>https://dev.to/hokagedemehin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hokagedemehin"/>
    <language>en</language>
    <item>
      <title>The purchase ledger: when did you actually buy that?</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 23 Aug 2026 00:04:07 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/the-purchase-ledger-when-did-you-actually-buy-that-5age</link>
      <guid>https://dev.to/hokagedemehin/the-purchase-ledger-when-did-you-actually-buy-that-5age</guid>
      <description>&lt;p&gt;The bug report was in the user's own words (the user is me, but the words are real): the shopping list "just constantly has items week in week out, getting more and more."&lt;/p&gt;

&lt;p&gt;The loop behind it is a genuine lose-lose. Clear the Done section and you lose the record of what you bought — the app's whole money story runs on that record. Don't clear it, and the working list grows forever until it stops being a working list. Every list app makes you pick one.&lt;/p&gt;

&lt;p&gt;The exit turned out to cost &lt;strong&gt;one schema field&lt;/strong&gt; — because the record wasn't actually being lost. This app never hard-deletes: cleared items were sitting in the table as soft-deleted rows the whole time. &lt;strong&gt;Nothing was reading them.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Bought = checked, and cleared rows survive (soft deletes) — the ledger already existed; the only missing &lt;em&gt;fact&lt;/em&gt; was &lt;strong&gt;when&lt;/strong&gt;. Add &lt;code&gt;ShoppingItem.checkedAt&lt;/code&gt; with three write semantics: a manual tick stamps &lt;em&gt;now&lt;/em&gt; &lt;strong&gt;into the mutation variables&lt;/strong&gt; (so an offline tick replayed tomorrow keeps today's moment); untick nulls it; a receipt match stamps the &lt;strong&gt;printed purchase date at midday local&lt;/strong&gt;. Then one documented exception to the house convention — history reads do &lt;strong&gt;not&lt;/strong&gt; filter &lt;code&gt;_deleted&lt;/code&gt; — turns Clear Done from destruction into archival. Week view is the raw shop; month/year views aggregate ×N by name; and the clear dialog stops apologising.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 18 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The insight: the ledger already exists
&lt;/h2&gt;

&lt;p&gt;Inventory of what the table already knew:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bought = checked.&lt;/strong&gt; A manual tick sets it, and &lt;a href="https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh"&gt;receipt matching&lt;/a&gt; sets it for every matched line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear Done soft-deletes.&lt;/strong&gt; The rows survive; the &lt;a href="https://dev.to/hokagedemehin/you-cant-group-by-in-dynamodb-so-the-client-does-the-counting-4dnd"&gt;soft-delete convention&lt;/a&gt; was paying dividends again — Part 10 mined these rows for frequency; nobody had mined them for &lt;em&gt;history&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Missing: &lt;strong&gt;when.&lt;/strong&gt; No timestamp recorded the purchase moment, and no screen bucketed purchases by period.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the feature is one field + one hook + one screen — not a re-architecture, and pointedly &lt;strong&gt;not a new model&lt;/strong&gt;. No &lt;code&gt;Trip&lt;/code&gt;, no &lt;code&gt;Purchase&lt;/code&gt; table: the new-kind test from Parts 11/13/14 says the rows already &lt;em&gt;are&lt;/em&gt; the record. The design sentence that anchored the whole thing: &lt;strong&gt;the working list stays a working list; History becomes the log.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One field, three time semantics
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;checkedAt: a.datetime()&lt;/code&gt; — and every writer answers "when?" differently, each for a reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A manual tick stamps &lt;em&gt;now&lt;/em&gt; — into the mutation variables.&lt;/strong&gt; This is a &lt;a href="https://dev.to/hokagedemehin/working-offline-in-the-cereal-aisle-tanstack-query-offline-on-amplify-5cn0"&gt;Part 7&lt;/a&gt; consequence that's easy to miss: offline mutations persist their &lt;em&gt;variables&lt;/em&gt; and replay later. Compute the timestamp inside the mutation function and a tick made in a signal-dead supermarket on Saturday, replayed when the app reopens on Sunday, records Sunday. So the moment is stamped where it survives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// checkedAt is stamped INTO the vars by onMutate (like addItems ids) so the&lt;/span&gt;
&lt;span class="c1"&gt;// tick's real moment survives a persisted offline mutation being replayed.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ToggleItemVars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;checkedAt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;An untick nulls it.&lt;/strong&gt; Unchecking means "I didn't buy this after all" — the item must vanish from history, not linger with a stale moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A receipt match stamps the receipt's printed date, at midday local:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// …so no timezone can nudge it into a neighbouring day. No date on the&lt;/span&gt;
&lt;span class="c1"&gt;// receipt → now.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checkedAtFromPurchaseDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;dayjs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;second&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;millisecond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;dayjs&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You might scan Tuesday's receipt on Friday; those purchases belong to Tuesday's week. It's Part 14's &lt;code&gt;purchaseDate&lt;/code&gt;-vs-&lt;code&gt;capturedAt&lt;/code&gt; reasoning extended item by item — and &lt;em&gt;midday&lt;/em&gt; local because a date pinned to noon can't drift into a neighbouring day in any timezone the phone wakes up in.&lt;/p&gt;

&lt;p&gt;And the fourth, quiet semantic: &lt;strong&gt;rows checked before the feature existed&lt;/strong&gt; have no &lt;code&gt;checkedAt&lt;/code&gt;, so the hook falls back to &lt;code&gt;updatedAt&lt;/code&gt; — approximate (an edit after buying shifts it), honest, and self-extinguishing since it only affects pre-feature rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The documented exception
&lt;/h2&gt;

&lt;p&gt;Every read in this app filters &lt;code&gt;_deleted: { ne: true }&lt;/code&gt;. Purchase history is the one deliberate exception, and the hook's docstring owns it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Bought = checked. The fetch deliberately has NO not-deleted filter — items
 * cleared from the Done section are soft-deleted but remain the archive this
 * screen reads. An item deleted while UNCHECKED never had a checkedAt, so
 * mistaken adds stay invisible.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exception holds because the argument is complete in both directions: an item deleted while &lt;strong&gt;unchecked&lt;/strong&gt; never got a &lt;code&gt;checkedAt&lt;/code&gt;, so mistaken adds and abandoned items can't pollute history; an item deleted &lt;strong&gt;after&lt;/strong&gt; being checked was genuinely bought — that's precisely the archive. Clear Done stops being destruction and becomes filing.&lt;/p&gt;

&lt;p&gt;Which enabled my favorite change in the feature — a line of copy. The clear-confirmation dialog used to carry the implicit apology every destructive dialog has. Now it says: &lt;em&gt;"Clear 12 done items? **They stay in History&lt;/em&gt;* — you can find them by week any time."* Clearing became the recommended rhythm, and the list finally stopped growing. The feature isn't the screen; the feature is that the dialog stopped apologising.&lt;/p&gt;

&lt;h2&gt;
  
  
  A week is a shop; a month is a habit
&lt;/h2&gt;

&lt;p&gt;The history screen (&lt;code&gt;Week | Month | Year&lt;/code&gt; segmented control, &lt;code&gt;‹ 20–26 Jul ›&lt;/code&gt; navigation, forward chevron disabled at the current period) renders &lt;strong&gt;different shapes per granularity&lt;/strong&gt;, not the same list with wider date bounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Week = the raw shop.&lt;/strong&gt; Items grouped by aisle, each with quantity, unit, and price where known — because a week is small enough that the individual shop &lt;em&gt;is&lt;/em&gt; the interesting object.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fstoz9eooq7ufhe96uas9.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fstoz9eooq7ufhe96uas9.jpeg" alt="The week view: the raw shop, grouped by aisle, with period navigation and the summary line" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Month/Year = aggregates.&lt;/strong&gt; Rows like &lt;em&gt;"Milk — ×4 · £4.92"&lt;/em&gt;, keyed by normalised &lt;strong&gt;name&lt;/strong&gt; (the same keying &lt;a href="https://dev.to/hokagedemehin/what-your-receipts-know-price-history-last-paid-trends-savings-231p"&gt;Part 16's savings&lt;/a&gt; chose, for the same reason — barcodes don't reach back through history), sorted by frequency. A month of raw rows would be noise; the aggregate answers the question a month actually poses: &lt;em&gt;what do I actually buy?&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17z17oybcwjxt2o4aq6z.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17z17oybcwjxt2o4aq6z.jpeg" alt="The month view: purchases aggregated ×N by product, sorted by how often you buy them" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each period's summary line counts the unpriced rather than quietly under-totalling — Part 13's honesty note, still on duty.&lt;/p&gt;

&lt;h2&gt;
  
  
  One creating action
&lt;/h2&gt;

&lt;p&gt;History is read-only except for one button: &lt;strong&gt;"Add these N again."&lt;/strong&gt; It turns whatever period you're looking at into next week's list — skipping anything already sitting unchecked on the active list and de-duplicating within the period, so N is what would &lt;em&gt;actually&lt;/em&gt; be added. The archive loops back into the working list in one tap, which is the moment the lose-lose from the top of this post fully dissolves: clear freely, because history is where next week's list comes from anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately is not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not a new model.&lt;/strong&gt; Checked items and receipts already are the record; a &lt;code&gt;Trip&lt;/code&gt; table would be a second copy of the truth to keep synced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a filter on the live list.&lt;/strong&gt; "Which week am I looking at?" is a question the working list must never answer — it is always &lt;em&gt;now&lt;/em&gt;. History is a different screen because it's a different tense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not auto-clearing.&lt;/strong&gt; Clearing stays a human action; the app just removed the punishment for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sometimes the feature is one timestamp.&lt;/strong&gt; The lose-lose loop, the growing list, the "where did my history go" anxiety — all resolved by recording &lt;em&gt;when&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conventions can carry documented exceptions&lt;/strong&gt; — when the coherence argument is airtight in both directions, write it in the docstring and break the rule on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stamp time into mutation variables.&lt;/strong&gt; Anything computed at execution time is recomputed at &lt;em&gt;replay&lt;/em&gt; time; offline apps must capture the moment where it persists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shape data per granularity.&lt;/strong&gt; A week and a month aren't the same query with different bounds; they're different questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The copy is part of the architecture.&lt;/strong&gt; The feature shipped when the clear dialog stopped apologising.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;The ledger immediately became a load-bearing wall: the Expenses tab's headline now sums &lt;em&gt;purchases&lt;/em&gt; rather than list state — which is what finally let budgets move off individual lists and onto &lt;strong&gt;months&lt;/strong&gt;, with a carry-forward rule that stops editing the past from rewriting the present. That rework is Part 19.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where does your app hoard state because deleting feels like losing data? A timestamp and a read-path exception might be the whole fix — what's your equivalent?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dynamodb</category>
      <category>reactnative</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Your app hardcodes GBP: currency as a first-class preference</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sat, 22 Aug 2026 23:36:10 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/your-app-hardcodes-gbp-currency-as-a-first-class-preference-3eda</link>
      <guid>https://dev.to/hokagedemehin/your-app-hardcodes-gbp-currency-as-a-first-class-preference-3eda</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/hokagedemehin/the-expenses-tab-mvp-prices-ride-on-the-items-you-already-have-18fn"&gt;Part 13&lt;/a&gt; confessed it in passing: &lt;code&gt;`£${(n ?? 0).toFixed(2)}`&lt;/code&gt; — the pound sign, hardcoded in a template string, in a file literally named &lt;code&gt;money.ts&lt;/code&gt;. A reasonable MVP shortcut for a single-region app.&lt;/p&gt;

&lt;p&gt;Then the app grew currency support for people who shop in naira, cedis, shillings and rand — and that one character became an audit. &lt;strong&gt;Currency isn't a constant. It's a user property masquerading as one&lt;/strong&gt; — and it was hiding in more places than any grep for &lt;code&gt;£&lt;/code&gt; suggested.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Model home currency on the &lt;em&gt;user&lt;/em&gt; (&lt;code&gt;UserProfile.homeCurrency&lt;/code&gt;), not the device. The interesting problem is that two kinds of code read it: React components need a &lt;strong&gt;subscription&lt;/strong&gt; (Redux, via &lt;code&gt;useMoney()&lt;/code&gt; — that's what re-renders every price on change), while pure functions outside React — selectors, chart formatters — need a &lt;strong&gt;module-level mirror&lt;/strong&gt; (&lt;code&gt;setHomeCurrency()&lt;/code&gt;). One setter updates both; skipping either leaves half the app in the old currency. Cache the pref so the first frame never flashes the wrong symbol; on first run &lt;strong&gt;guess the country from the device locale but commit only on Continue&lt;/strong&gt;; and when the user changes currency, &lt;strong&gt;relabel, never convert — and say so in the dialog.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 17 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit
&lt;/h2&gt;

&lt;p&gt;The grep for &lt;code&gt;£&lt;/code&gt; was the easy part. The full list of places a currency assumption hides, from this codebase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the two core formatters (&lt;code&gt;formatMoney&lt;/code&gt;, &lt;code&gt;formatPennies&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;the compact chart-label format (&lt;code&gt;£1.2k&lt;/code&gt;) from &lt;a href="https://dev.to/hokagedemehin/what-your-receipts-know-price-history-last-paid-trends-savings-231p"&gt;Part 16&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;the AmountSheet's currency symbol next to its numeric input&lt;/li&gt;
&lt;li&gt;input placeholders and empty-state copy ("Set a budget like £30")&lt;/li&gt;
&lt;li&gt;receipt rows and month subtotals&lt;/li&gt;
&lt;li&gt;savings headlines ("Save £3.20")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One preference, a dozen leaks. Which is the argument for fixing it as a &lt;strong&gt;sweep with a design&lt;/strong&gt;, not incrementally: every incremental fix invents its own way to learn the currency, and you end up with three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: on the user, not the device
&lt;/h2&gt;

&lt;p&gt;Two fields on the profile row: &lt;code&gt;homeCurrency&lt;/code&gt; (ISO 4217) and &lt;code&gt;countryCode&lt;/code&gt;. Per-&lt;strong&gt;user&lt;/strong&gt;, not per-device — sign in on a new phone and your naira comes with you. The device's locale is a &lt;em&gt;hint&lt;/em&gt; (more below), never the storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-readers problem
&lt;/h2&gt;

&lt;p&gt;Here's the architectural heart. Code that formats money comes in two species:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Components&lt;/strong&gt; need &lt;em&gt;reactivity&lt;/em&gt;: when the preference changes, every visible price must re-render. So components use a &lt;code&gt;useMoney()&lt;/code&gt; hook that reads &lt;code&gt;state.prefs.homeCurrency&lt;/code&gt; from Redux — the subscription &lt;strong&gt;is&lt;/strong&gt; the re-render mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure functions&lt;/strong&gt; — the expenses selectors, chart label formatters, anything callable outside a component — can't subscribe to anything. For them, &lt;code&gt;money.ts&lt;/code&gt; keeps a module-level mirror. The comment in the real file is the design note:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Module-level mirror of the user's preference, set once at launch and on
 * change via setHomeCurrency(). Kept here so the pure formatters below stay
 * callable from non-component code (selectors, helpers) with no plumbing.
 * Components read `state.prefs.homeCurrency` from Redux — that's what makes a
 * change actually re-render; this mirror only keeps the formatting correct.
 */&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;homeCurrency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_HOME_CURRENCY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setHomeCurrency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;homeCurrency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normaliseCurrency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_HOME_CURRENCY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure mode this prevents: update Redux but not the mirror and your components relabel while your chart labels and selector output stay in pounds — half an app in each currency. So there is exactly &lt;strong&gt;one entry point&lt;/strong&gt;, &lt;code&gt;applyCurrencyPref()&lt;/code&gt;, which dispatches to Redux &lt;em&gt;and&lt;/em&gt; calls &lt;code&gt;setHomeCurrency()&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; persists the cache. Two stores, one setter, no drift.&lt;/p&gt;

&lt;p&gt;(If your state library exposes reading outside React — &lt;code&gt;store.getState()&lt;/code&gt; — you could skip the mirror; the trade is that every pure helper grows a store import. The mirror keeps &lt;code&gt;money.ts&lt;/code&gt; dependency-free, which is also what keeps it trivially testable.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Never flash the wrong symbol
&lt;/h2&gt;

&lt;p&gt;The durable source of truth is the profile row — which arrives over the network. Format from that alone and every launch renders &lt;code&gt;£&lt;/code&gt; for a second or two before snapping to &lt;code&gt;₦&lt;/code&gt;. The fix is a cache with a stated job, from the real file header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Home-currency preference. UserProfile is the durable source of truth;&lt;/span&gt;
&lt;span class="c1"&gt;// AsyncStorage caches it so formatting is correct on the very first frame,&lt;/span&gt;
&lt;span class="c1"&gt;// before the profile query resolves (otherwise every amount would flash £).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Launch applies the cached pref immediately; a small &lt;code&gt;CurrencySync&lt;/code&gt; component reconciles once the profile loads. A flickering currency symbol is a tiny bug that reads as a deeply untrustworthy app — it's &lt;em&gt;money&lt;/em&gt; wearing the wrong flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: guess, then confirm
&lt;/h2&gt;

&lt;p&gt;New users get a one-time country picker, and it opens &lt;strong&gt;pre-aimed&lt;/strong&gt;: the device's region floats to the top of the suggestions. The detection is one honest function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// …saved until they tap Continue. Kept a function, not a module constant, so&lt;/span&gt;
&lt;span class="c1"&gt;// the native read happens on mount rather than at import.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectCountry&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;CountryCurrency&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getLocales&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;regionCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;COUNTRY_CURRENCIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;countryCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deliberate choices in those six lines. It's a &lt;strong&gt;function, not a module constant&lt;/strong&gt; — the React Compiler (rightly) rejects impure native reads in render, and an import-time read would run before the native module is warm; the lazy &lt;code&gt;useState(detectCountry)&lt;/code&gt; initializer runs it exactly once, on mount. And the result is a &lt;strong&gt;pre-filled guess, not a decision&lt;/strong&gt; — it commits only when the user taps Continue (or Skip, which applies the default). Guessing saves the 95% case a scroll through 200 countries; &lt;em&gt;confirming&lt;/em&gt; saves the traveler whose phone thinks they live at the airport.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkg3qe4nv8rlmw82ukiz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkg3qe4nv8rlmw82ukiz.png" alt="First-run onboarding: the device's country floated to the top as a suggestion, committed only on Continue" width="800" height="1739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more belt-and-braces detail from the pref module: completion is also flagged locally, because if the profile write fails (offline, transient), the user must not be re-prompted on every launch — &lt;em&gt;"the flag is the belt, homeCurrency the braces."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Relabel, never convert
&lt;/h2&gt;

&lt;p&gt;The sharpest product decision in the feature is what changing your currency &lt;strong&gt;doesn't&lt;/strong&gt; do. It relabels — &lt;code&gt;1.30&lt;/code&gt; that was £1.30 is now ₦1.30 — and the confirmation dialog says exactly that, in words, before you confirm.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhk8whfjtal1136q0j8k6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhk8whfjtal1136q0j8k6.png" alt="Changing currency: the confirmation spells out that amounts are relabelled, never converted" width="800" height="1739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Converting would require an FX rate, a rate &lt;em&gt;date&lt;/em&gt;, and an answer to "converted as of when?" for every historical amount — silently, invisibly, behind a settings toggle. Relabeling is honest about being dumb; conversion would be dishonest about being smart. The supporting cast from earlier posts follows the same line: receipts keep the currency they were recorded in forever, month subtotals stay per-currency, and Part 16's trends exclude-and-count foreign receipts rather than blending them. Actual FX conversion has a written plan doc and deliberately zero code — it only earns its complexity if foreign receipts actually accumulate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sibling audit: dates
&lt;/h2&gt;

&lt;p&gt;The same sweep centralised every date operation into one dayjs module, and its header comment carries the war story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Every date format and calculation in the app, on dayjs.
 *
 * - `new Date("2026-07-21")` parses as UTC midnight, which renders as the
 *   PREVIOUS day anywhere west of Greenwich — a receipt dated the 21st showing
 *   as the 20th. `dayjs("2026-07-21")` parses as local, so `parseISODate` can't
 *   reproduce that bug.
 * - The month names, "14 Jul" and "July 2026" had drifted into seven separate
 *   copies. One definition means one place to change.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Date-only strings are timezone landmines: &lt;code&gt;new Date("2026-07-21")&lt;/code&gt; is &lt;em&gt;UTC midnight&lt;/em&gt;, which is yesterday evening in New York — a receipt's purchase date (that carefully timezone-proofed &lt;code&gt;AWSDate&lt;/code&gt; from &lt;a href="https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh"&gt;Part 14&lt;/a&gt;) would have shifted a day at the very last step, in the render. And the same React Compiler purity rule made a second cameo: anything reading the clock stays a &lt;strong&gt;function&lt;/strong&gt;, never a module constant — &lt;code&gt;today()&lt;/code&gt; computed at import time is a stale date by breakfast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User properties masquerade as constants.&lt;/strong&gt; Currency, locale, units, first-day-of-week — if two users could disagree, it's a preference with storage, not a literal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two kinds of readers need two stores — behind one setter.&lt;/strong&gt; Subscriptions for components, a module mirror for pure functions, and a single &lt;code&gt;applyCurrencyPref()&lt;/code&gt; so they can't drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache-first display&lt;/strong&gt; or the first frame lies. Money flashing the wrong symbol is trust damage out of proportion to the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guess, then confirm.&lt;/strong&gt; Device locale aims the picker; only the user commits it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relabel ≠ convert — say so in the UI.&lt;/strong&gt; The honest dumb behavior beats the silent clever one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never &lt;code&gt;new Date("YYYY-MM-DD")&lt;/code&gt;.&lt;/strong&gt; One centralised date module, and the clock stays a function.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Every amount in the app now wears the right symbol — but there's a deeper question hiding under the Expenses tab: &lt;em&gt;when did you actually buy that?&lt;/em&gt; Ticking an item and scanning a receipt disagree about time in a subtle way, and resolving it produced the app's purchase ledger. That's next.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's hardcoded in your app right now that's actually a user preference? Be honest — mine was in a file called &lt;code&gt;money.ts&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>i18n</category>
      <category>mobile</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>What your receipts know: price history, "last paid", trends, savings</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sat, 22 Aug 2026 16:51:01 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/what-your-receipts-know-price-history-last-paid-trends-savings-231p</link>
      <guid>https://dev.to/hokagedemehin/what-your-receipts-know-price-history-last-paid-trends-savings-231p</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/hokagedemehin/barcode-lookup-a-provider-chain-with-an-llm-as-the-normaliser-33ha"&gt;Part 15&lt;/a&gt; established an uncomfortable fact: there is &lt;strong&gt;no free, legitimate API for supermarket prices&lt;/strong&gt;. Scraping is off the table; the paid aggregators price by the request. For a solo side project, that looked like a dead end for anything "deals"-shaped.&lt;/p&gt;

&lt;p&gt;But something changed while I wasn't looking. After weeks of &lt;a href="https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh"&gt;scanning receipts&lt;/a&gt;, the user now &lt;em&gt;owns&lt;/em&gt; a price dataset nobody sells: every price they actually paid, at the shops they actually use. This post builds three screens on it — per-product &lt;strong&gt;price history&lt;/strong&gt;, &lt;strong&gt;spend trends&lt;/strong&gt;, and a &lt;strong&gt;savings&lt;/strong&gt; screen — and spends most of its words on the part that mattered: the gates that stop those screens from lying.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Your users' own history beats no data &lt;em&gt;and&lt;/em&gt; beats scraped data: it's free, legal, and true for &lt;em&gt;them&lt;/em&gt;. The design work is honesty: freshness windows before a cheaper past price counts as actionable, per-store totals only, a two-distinct-prices minimum before anything claims a saving, foreign receipts excluded &lt;em&gt;and counted on screen&lt;/em&gt; — and every one of those gates lives in the &lt;strong&gt;selector&lt;/strong&gt;, not the UI, so no screen can accidentally bypass them. Also: chart libraries mutate their own copy of your data; read tooltips from your series, not theirs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 16 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The dataset you accidentally built
&lt;/h2&gt;

&lt;p&gt;Every scanned receipt stores a frozen &lt;code&gt;lineItems&lt;/code&gt; snapshot and a &lt;code&gt;purchaseDate&lt;/code&gt; — the timezone-proof, date-only field from Part 14 that records when you actually &lt;em&gt;shopped&lt;/em&gt;, not when you scanned. Nothing in this post adds a model or a Lambda. All three screens are &lt;strong&gt;pure selectors&lt;/strong&gt; over receipts already in the cache, which also means all three work offline. The interesting engineering is entirely in what the selectors refuse to claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start small: "you last paid £2.40"
&lt;/h2&gt;

&lt;p&gt;The gentlest feature first: when you price an item by hand, the amount sheet now &lt;em&gt;suggests what you last paid&lt;/em&gt;. The lookup matches &lt;strong&gt;by barcode when the item has one&lt;/strong&gt; — exact, thanks to Part 15 — and &lt;strong&gt;falls back to normalised name&lt;/strong&gt; otherwise, which also covers everything added by voice or typing. A tiny feature, but it set the matching precedent the rest of the post keeps renegotiating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Price history: the chart that zooms, honestly
&lt;/h2&gt;

&lt;p&gt;Tap any product and you get its unit-price history as a line chart. The interesting decision is the y-axis: it &lt;strong&gt;zooms to the range of prices paid&lt;/strong&gt; rather than starting at zero. Against a zero baseline, a price that moved from £2.20 to £2.40 is a flat line pinned to the top rule — the chart would hide exactly the thing it exists to show.&lt;/p&gt;

&lt;p&gt;Zoomed axes are how charts famously lie, so this one buys its legitimacy explicitly: &lt;strong&gt;no y-axis numbers are drawn at all&lt;/strong&gt;, and every point is tappable, showing the real price in a readout line. You can't misread an axis that isn't there; you can only tap and see the truth.&lt;/p&gt;

&lt;p&gt;Then the library trap. &lt;code&gt;react-native-gifted-charts&lt;/code&gt; implements the zoom via &lt;code&gt;yAxisOffset&lt;/code&gt; — by &lt;strong&gt;subtracting the offset from its own copy of your data&lt;/strong&gt;. Wire the tap-readout to the chart's item and it happily reports £0.20 for a £2.40 price. The fix is in a comment in the real component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Tap → readout line above the chart (index into OUR series: the&lt;/span&gt;
&lt;span class="c1"&gt;// chart's copy has the zoom offset subtracted, so its values are&lt;/span&gt;
&lt;span class="c1"&gt;// not the prices paid). No floating tooltip, nothing to clip.&lt;/span&gt;
&lt;span class="nx"&gt;focusEnabled&lt;/span&gt;
&lt;span class="nx"&gt;showStripOnFocus&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chart renders geometry; &lt;strong&gt;your data stays the source of truth&lt;/strong&gt;. Anything a user reads as a number comes from your series, by index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spend trends: deltas need equal windows
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/expenses/trends&lt;/code&gt; buckets receipts by &lt;code&gt;purchaseDate&lt;/code&gt; over a 3-, 6- or 12-month range: a headline total, monthly bars, and ranked lists of stores, aisles, and most-bought products. Two rules keep the numbers honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The delta compares equal windows.&lt;/strong&gt; "Down 12%" means this 3 months vs the &lt;em&gt;previous&lt;/em&gt; 3 months — the prior window is loaded solely to feed that comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign receipts are excluded — and counted, on screen.&lt;/strong&gt; The app supports many currencies but converts nothing (a later post). A euro receipt in a GBP total would be nonsense; silently dropping it would be worse. So the total excludes it and the screen says so: &lt;em&gt;"2 receipts in other currencies not included."&lt;/em&gt; Never let a number quietly mean less than it appears to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1znj1a5yrxrtg4ev106p.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1znj1a5yrxrtg4ev106p.jpeg" alt="The Trends screen: monthly spend bars with the delta vs the previous window, and ranked stores and aisles below" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Charts on a phone: practical scars
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The library is &lt;strong&gt;pure JS&lt;/strong&gt; — chosen partly because Part 14 taught me what native modules cost (a full dev-client rebuild). Charts arrived with zero build-system involvement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size charts from &lt;code&gt;useWindowDimensions&lt;/code&gt;, not constants.&lt;/strong&gt; The library's fixed default geometry overflows the card at exactly 12 bars — which a 12-month range produces.&lt;/li&gt;
&lt;li&gt;The compact money format (&lt;strong&gt;£1.2k&lt;/strong&gt;) exists solely for chart labels, which live in boxes a few characters wide. Everywhere else gets full amounts.&lt;/li&gt;
&lt;li&gt;Thousands separators are &lt;strong&gt;grouped by hand, not &lt;code&gt;Intl&lt;/code&gt;&lt;/strong&gt; — Hermes ships a real &lt;code&gt;Intl&lt;/code&gt; on some platforms and a stub on others, and a silently ungrouped total is exactly the kind of bug you only meet in production screenshots.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Savings: coral's screen, honestly earned
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/expenses/savings&lt;/code&gt; is the one screen where coral — the design system's money accent, hoarded for fifteen posts — is &lt;em&gt;the&lt;/em&gt; color. Which is why its numbers had to be the most defensible in the app. With no price API, a "deal" is redefined as something provable from the user's own receipts, and the module docstring is the spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Deals and savings, derived entirely from the user's own receipts — there is no
 * legitimate free UK supermarket price API, so a "deal" here means *you have paid
 * less for this before, at this shop, recently*.
 *
 * Three gates keep that claim honest, and none of them are optional:
 *
 * 1. FRESH_DAYS — a cheaper price from nine months ago is not an offer. Stale
 *    ones are still listed, with their date, but never counted in a headline.
 * 2. Per-store totals only. Summing savings across five shops assumes a trip to
 *    all five; the headline is always "£x at one named shop".
 * 3. Two purchases at two distinct prices before an item can claim a saving —
 *    something bought once is merely its own only price.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FRESH_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="cm"&gt;/** Under 5p is noise, not a deal. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_SAVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gate 3 is the one that bites early users: the thing usually missing isn't a second &lt;em&gt;shop&lt;/em&gt;, it's a second &lt;em&gt;price&lt;/em&gt; — and that's fine, because one shop's own price moving over time is a real, actionable difference. (And the bonus micro-gate: savings under 5p don't count. Noise isn't a deal.)&lt;/p&gt;

&lt;p&gt;The structural decision: &lt;strong&gt;all three gates live in the selector, not the UI.&lt;/strong&gt; No screen decides what counts as a saving; screens only render what &lt;code&gt;useSavings&lt;/code&gt; already vouches for. A future widget, Home card, or notification reusing the selector inherits the honesty for free — the same pattern as Part 13's budget selector, now carrying real editorial weight.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzkp8ygezzf225gegkxuu.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzkp8ygezzf225gegkxuu.jpeg" alt="The Savings screen: per-store deals from your own receipts — the one screen where coral is the accent" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The keying asymmetry
&lt;/h2&gt;

&lt;p&gt;Here's the subtle one. The savings selector keys products by &lt;strong&gt;normalised name&lt;/strong&gt;. The price-history lookup keys &lt;strong&gt;barcode-first&lt;/strong&gt;. Same dataset, opposite choices — both deliberate.&lt;/p&gt;

&lt;p&gt;Only receipts logged &lt;em&gt;after&lt;/em&gt; barcode scanning shipped carry barcodes. Key savings by barcode and every product splits into a pre-barcode era and a post-barcode era — half the history on each side, neither half clearing the two-purchases gate, savings understated across the board. Name-keying heals the split. But when you tap &lt;em&gt;one specific product&lt;/em&gt; to see its history, exactness wins — barcode when present, name as fallback.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: &lt;strong&gt;key by the question, not by the schema.&lt;/strong&gt; "How has what I pay for milk moved?" and "show me &lt;em&gt;this&lt;/em&gt; product" are different questions and deserve different joins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Risers, demoted on purpose
&lt;/h2&gt;

&lt;p&gt;The screen also lists price &lt;em&gt;rises&lt;/em&gt; — with two deliberate suppressions. Rises compare &lt;strong&gt;latest vs the time before&lt;/strong&gt;, never vs cheapest-ever: measure against the all-time low and one long-gone promotion marks an item "rising" forever. And risers live in a &lt;strong&gt;collapsed footer&lt;/strong&gt;, below the savings: a rise is the one thing on this screen you can't act on. Information you can't act on doesn't get to shout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your users' own history is a dataset nobody sells&lt;/strong&gt; — free, legal, and true for them in a way no scraped price ever is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honesty gates belong in selectors.&lt;/strong&gt; Freshness windows, minimum evidence, per-store scoping — enforce them where every consumer inherits them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derived claims need expiry dates.&lt;/strong&gt; A cheaper price from March isn't an offer in August; show it, dated, but never count it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chart libraries lie about their own data.&lt;/strong&gt; Zoomed axes mutate copies; read every user-visible number from your series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key by the question, not the schema&lt;/strong&gt; — and expect the right key to differ per screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demote the unactionable.&lt;/strong&gt; Collapsed footers are a design feature, not a failure state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Every number in this post wore a &lt;code&gt;£&lt;/code&gt; — and for the app's first weeks that symbol was hardcoded in a template string. Part 17 is the audit that fixed it: currency as a first-class user preference, from first-run country detection to the module-mirror trick that keeps pure formatters working outside React.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What does your product derive from data users already gave you — and what gates keep those derived numbers honest? I'd genuinely like to steal your rules.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>datavis</category>
      <category>mobile</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Barcode lookup: a provider chain with an LLM as the normaliser</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sat, 22 Aug 2026 16:09:11 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/barcode-lookup-a-provider-chain-with-an-llm-as-the-normaliser-33ha</link>
      <guid>https://dev.to/hokagedemehin/barcode-lookup-a-provider-chain-with-an-llm-as-the-normaliser-33ha</guid>
      <description>&lt;p&gt;The app now has three input devices: your voice, your camera pointed at paper, and — new this post — your camera pointed at a &lt;strong&gt;barcode&lt;/strong&gt;. The first two hand Claude an ambiguity problem: rambling speech, crumpled thermal print. A barcode is the opposite. &lt;code&gt;5000169005804&lt;/code&gt; identifies exactly one product on Earth. No interpretation permitted.&lt;/p&gt;

&lt;p&gt;That inversion drives every architectural decision in this feature — including the strictest rule in the codebase: &lt;strong&gt;Claude is never allowed to invent a product.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Exact inputs get &lt;em&gt;lookups&lt;/em&gt;, not generation. The lookup is an ordered &lt;strong&gt;provider chain&lt;/strong&gt; (own cache → four Open*Facts databases, which share one API so they're one adapter with four hosts), each provider behind a &lt;strong&gt;3-second fuse&lt;/strong&gt;, failures skipped never fatal. Claude's only job is &lt;strong&gt;normalising&lt;/strong&gt; the messy winning payload (trimmed to 11 fields first) into a clean name, parsed size, and one of the app's own aisle keys. Nothing found → &lt;code&gt;found: false&lt;/code&gt; and fast manual entry — whose answer is &lt;strong&gt;written into a shared, deliberately &lt;code&gt;userId&lt;/code&gt;-less &lt;code&gt;Product&lt;/code&gt; cache&lt;/strong&gt;, so coverage accumulates exactly where the global databases are blank.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 15 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The feasibility memo came first
&lt;/h2&gt;

&lt;p&gt;Before any code, a plan-doc reckoning with two uncomfortable facts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product identification is solved for Europe and thin elsewhere.&lt;/strong&gt; The &lt;a href="https://world.openfoodfacts.org" rel="noopener noreferrer"&gt;Open*Facts family&lt;/a&gt; — Open Food Facts plus its Beauty, Products and Pet Food siblings — is free, open, keyless, and strong in the UK. But the app supports NGN, GHS, KES and ZAR currencies, and locally-produced goods in Lagos or Nairobi are usually in &lt;strong&gt;no global database at all&lt;/strong&gt;. So manual entry is designed as a &lt;em&gt;main path&lt;/em&gt;, not an apologetic fallback — and (spoiler) it feeds the cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no free, legitimate API for live UK supermarket prices.&lt;/strong&gt; Tesco and Sainsbury's publish none. Scraping breaks their terms and breaks constantly in practice — off the table entirely. The honest alternative: &lt;em&gt;best price from data the user already owns&lt;/em&gt; — their own &lt;a href="https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh"&gt;scanned receipts&lt;/a&gt;. Free, legal, and accurate for &lt;em&gt;their&lt;/em&gt; shops, which a web price never is.&lt;/p&gt;

&lt;p&gt;Both findings are in the plan doc with a warning I'll repeat here: free-tier terms change — verify before integrating, and don't trust the figures in anyone's plan doc, including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provider chain
&lt;/h2&gt;

&lt;p&gt;The four Open*Facts databases share one API shape — &lt;code&gt;/api/v2/product/{barcode}.json&lt;/code&gt; — so all four are &lt;strong&gt;one adapter with four hosts&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OPEN_FACTS_HOSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openfoodfacts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world.openfoodfacts.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openbeautyfacts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world.openbeautyfacts.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openproductsfacts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world.openproductsfacts.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openpetfoodfacts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world.openpetfoodfacts.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chain runs: &lt;strong&gt;our own &lt;code&gt;Product&lt;/code&gt; cache&lt;/strong&gt; (checked on the client, before the Lambda is ever invoked — a repeat scan resolves instantly and costs nothing) → the four databases in order → not found. Three rules make it safe to stand in an aisle with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Per-provider fuse. The user is stood in an aisle — a slow answer is worse&lt;/span&gt;
&lt;span class="c1"&gt;// than a fast "not found", which still leads to a quick manual add.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PROVIDER_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchWithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;USER_AGENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// timeout or network error — skip this provider, never fatal&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every provider has a fuse.&lt;/strong&gt; A hung host degrades to "try the next one," never to a spinner in a supermarket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First good answer wins.&lt;/strong&gt; No merging of providers — two conflicting names are harder to reason about than one imperfect record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures are skipped, never fatal.&lt;/strong&gt; One database being down must not break lookup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paid catalogs (UPCitemdb, Go-UPC…) slot in at the end of the same list behind &lt;code&gt;enabled&lt;/code&gt; flags that read env keys. Adding or dropping a provider is a change &lt;em&gt;in the Lambda only&lt;/em&gt; — no client work, no app-store release. The chain is the API.&lt;/p&gt;

&lt;p&gt;(Also: the &lt;code&gt;User-Agent&lt;/code&gt; is a real, descriptive one. Open Food Facts is a volunteer project that asks callers to identify themselves; being a good citizen of a free database costs one string.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude the normaliser, never the generator
&lt;/h2&gt;

&lt;p&gt;The winning payload is a mess by design — community-entered data: &lt;code&gt;categories_tags: ["en:snacks"]&lt;/code&gt;, product names in mixed languages, &lt;code&gt;"415 g"&lt;/code&gt; as one opaque string, brands as comma lists. This is exactly an LLM-shaped problem, with one twist: before Claude sees it, the payload is &lt;strong&gt;trimmed to 11 fields&lt;/strong&gt; — the full Open Food Facts record is enormous (nutrition matrices, image revisions, edit history) and almost all of it is irrelevant to a shopping list. Don't pay tokens for data you're going to ignore.&lt;/p&gt;

&lt;p&gt;The prompt's job description is deliberately narrow:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;name: the product WITHOUT the brand (brand has its own field), title-cased …&lt;/li&gt;
&lt;li&gt;quantity + unit: the size parsed ("415 g" → quantity 415, unit "g") …&lt;/li&gt;
&lt;li&gt;category: choose EXACTLY ONE from this list, or null when genuinely unclear: produce, bakery, dairy, meat, … pet, other&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never invent details that are not supported by the record. Prefer null over a guess.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;That category rule is &lt;a href="https://dev.to/hokagedemehin/whose-categories-are-these-making-an-llms-throwaway-labels-feel-like-real-things-4bjg"&gt;Part 9&lt;/a&gt;'s lesson applied again: the model maps into the app's &lt;strong&gt;owned vocabulary&lt;/strong&gt; of aisle keys — it never mints its own labels.&lt;/p&gt;

&lt;p&gt;And when &lt;em&gt;no&lt;/em&gt; provider knows the barcode, the Lambda returns &lt;code&gt;{ found: false, barcode }&lt;/code&gt;. It does &lt;strong&gt;not&lt;/strong&gt; ask Claude to conjure a product from thirteen digits. A confidently wrong product is worse than no product — it's unverifiable, and it would poison a shared cache. The client answers "not found" with a manual-entry form instead, name field focused, one tap from the scanner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache with no &lt;code&gt;userId&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Every model in this app carries &lt;code&gt;userId&lt;/code&gt; — the owner's Cognito sub. &lt;code&gt;Product&lt;/code&gt; is the first deliberate exception:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A barcode means the same product for everyone.&lt;/strong&gt; Barcode → product is a global fact, so the cache is a global model. One user's lookup benefits the next person to scan the same thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual entries are cached too&lt;/strong&gt; — &lt;code&gt;source: "manual"&lt;/code&gt;. This is the quiet best decision in the feature: the not-found path &lt;em&gt;writes back&lt;/em&gt;, so the cache grows precisely where the global databases are blank. A user in Accra scanning local products builds coverage no European database will ever have. A dead end becomes an asset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The provider's &lt;code&gt;raw&lt;/code&gt; payload is kept on the row&lt;/strong&gt;, so products can be re-normalised later (better prompt, better model) without re-fetching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A world-writable shared cache has an obvious failure mode — one user's typo becoming everyone's product name — so v1 ships guards: &lt;strong&gt;first write wins, existing products are never overwritten&lt;/strong&gt;, and &lt;code&gt;source&lt;/code&gt; makes manual rows auditable and supersedable by a later real provider hit. The plan doc also states plainly what's deferred: if this ever goes properly multi-user, it needs moderation or per-user overrides — a decision written down &lt;em&gt;before&lt;/em&gt; the scale that forces it, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scanner is mostly restraint
&lt;/h2&gt;

&lt;p&gt;The camera half of the feature is a list of things carefully &lt;em&gt;not&lt;/em&gt; done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A camera fires the same barcode many times a second — a &lt;strong&gt;scan lock&lt;/strong&gt; ensures exactly one lookup per capture.&lt;/li&gt;
&lt;li&gt;Scanning the same product again &lt;strong&gt;bumps its quantity&lt;/strong&gt; instead of duplicating the row.&lt;/li&gt;
&lt;li&gt;Lookups run in the background; the viewfinder never blocks on the network.&lt;/li&gt;
&lt;li&gt;Symbologies are restricted to product codes (EAN-13/8, UPC-A/E) — a narrow set misreads far less than everything-on.&lt;/li&gt;
&lt;li&gt;Scans accumulate in a &lt;strong&gt;staging tray&lt;/strong&gt; reviewed once at the end. A confirmation modal between every scan is the wrong design for a human pushing a trolley; batch capture, single review — the same review-gate pattern as voice and receipts.&lt;/li&gt;
&lt;li&gt;The sheet dismisses on an explicit ✕ only — no swipe-down, no backdrop tap. An over-scroll must never throw away a reviewed trolley.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6lq9ahwmxdzkqzvhpy9h.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6lq9ahwmxdzkqzvhpy9h.jpeg" alt="The scanner: viewfinder locked on a barcode, torch and manual-entry controls, with already-scanned items staged below" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The silence is the signal
&lt;/h2&gt;

&lt;p&gt;The feedback design earns its own section because the &lt;em&gt;absence&lt;/em&gt; of feedback is doing the work. A &lt;strong&gt;new&lt;/strong&gt; capture chirps (a real shutter-adjacent beep) and taps a success haptic. A &lt;strong&gt;duplicate&lt;/strong&gt; scan gets &lt;strong&gt;silence plus a warning haptic&lt;/strong&gt; — and that contrast is what teaches users to stop re-scanning: no beep, nothing new. The audio is configured to mix with (iOS) or duck (Android) whatever's playing rather than interrupting it, and the iOS silent switch is respected — haptics still confirm when the phone is muted. Manual entry never beeps; it isn't a capture.&lt;/p&gt;

&lt;p&gt;Designing what &lt;em&gt;doesn't&lt;/em&gt; make a sound turned out to matter as much as what does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coral's debut
&lt;/h2&gt;

&lt;p&gt;One more thing appears in the batch review sheet: &lt;em&gt;"Last paid £2.40 · cheapest £1.95."&lt;/em&gt; It's computed from the user's own receipt history — matched by &lt;strong&gt;normalised name&lt;/strong&gt; rather than barcode (receipt lines predate the barcode field, and name-matching also covers items added by voice or hand), home-currency receipts only, unit prices so quantity never distorts a comparison.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcf5hdoqnydqqpdhcj5p8.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcf5hdoqnydqqpdhcj5p8.jpeg" alt="The batch review sheet: each scanned product editable, with what you last paid pulled from your own receipts" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's also the &lt;strong&gt;first place the coral accent appears in the app&lt;/strong&gt; — the color the design system reserves exclusively for money-saving moments, unspent through fourteen posts. What your receipts know deserves more than a paragraph, though: it's the whole next post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exact inputs get normalisers, not generators.&lt;/strong&gt; The moment the input identifies one true answer, the LLM's job is cleanup — and "prefer null over a guess" belongs in the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chains need fuses.&lt;/strong&gt; Per-provider timeouts and skip-on-failure turn four flaky free databases into one dependable lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global facts get global caches.&lt;/strong&gt; Dropping &lt;code&gt;userId&lt;/code&gt; from one model — deliberately, with guards — turned every user's scans into shared infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feed the cache from the failure path.&lt;/strong&gt; Manual entry after not-found is how the app builds coverage no provider sells.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim before you prompt.&lt;/strong&gt; Eleven fields in, not four hundred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design the silence.&lt;/strong&gt; A duplicate that doesn't beep teaches more than any toast.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Items now carry barcodes, receipts carry prices, and the app has months of both. Post 16 wires it together: &lt;strong&gt;per-item price history, spend trends, and the savings screen&lt;/strong&gt; — what "deals" look like when your only price source is your own past.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where do you put the line between lookup and generation in your LLM features — and have you ever let a model fill a gap it shouldn't have?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>architecture</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Point your camera at a receipt and Claude reads it</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sat, 22 Aug 2026 15:26:23 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh</link>
      <guid>https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/hokagedemehin/the-expenses-tab-mvp-prices-ride-on-the-items-you-already-have-18fn"&gt;Last post&lt;/a&gt; ended with an admission: after a real shop, nobody prices 30 items by hand. The prices already exist — printed on a crumpled receipt at the bottom of a shopping bag. So this post is the app's headline feature: &lt;strong&gt;photograph the receipt, let Claude's vision read it, and watch the list get priced and ticked in one pass.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's also the feature that forced three firsts: the app's first &lt;strong&gt;native modules&lt;/strong&gt; (goodbye, JS-only development), its first &lt;strong&gt;storage bucket&lt;/strong&gt;, and — a day later — its first genuinely &lt;strong&gt;new data model&lt;/strong&gt; of the money arc. And it delivered the project's two best war stories so far: an IAM error that looked impossible, and a save that failed silently for an entire session.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — The pipeline is a chain of &lt;em&gt;boundaries&lt;/em&gt;: the client captures and compresses; the image crosses AppSync as an &lt;strong&gt;S3 key&lt;/strong&gt; (never base64); the &lt;strong&gt;Lambda only extracts&lt;/strong&gt; (Claude vision, strict JSON, domain rules in the prompt); the &lt;strong&gt;client only matches&lt;/strong&gt; (it owns the list); the &lt;strong&gt;user confirms&lt;/strong&gt; in a review sheet with a total-reconciliation line; the stored &lt;code&gt;Receipt&lt;/code&gt; is an &lt;strong&gt;immutable snapshot&lt;/strong&gt;. War story 1: owner-scoped S3 rules don't cover Cognito &lt;strong&gt;group roles&lt;/strong&gt; — every user assumed one, so every upload got &lt;code&gt;AccessDenied&lt;/code&gt;. War story 2: Amplify's client returns &lt;code&gt;{ data, errors }&lt;/code&gt; without throwing, and &lt;code&gt;a.json()&lt;/code&gt; fields want a &lt;em&gt;string&lt;/em&gt; — a raw array failed validation invisibly until errors were surfaced.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 14 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, end to end
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;camera / photo library
  → resize ~1500px, JPEG q0.6        (expo-image-manipulator)
  → upload to S3                      receipts/{identityId}/{uuid}.jpg
  → parseReceipt({ key })             (custom mutation → Lambda)
  → Claude Haiku vision               (strict-JSON extraction)
  → review sheet                      (match · edit · confirm)
  → normal app mutations              (price + tick, or add as bought)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions shape everything downstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The image travels as an S3 key, never base64 through AppSync.&lt;/strong&gt; GraphQL payloads have limits and costs; more importantly, uploading first means the original is &lt;em&gt;kept&lt;/em&gt;. A receipt is a record — the key lives on the row for audit and re-scan. (The app's other photo feature, scanning a handwritten list, deletes its image after parsing — a list photo is a means to an end, a receipt is an end. Same pipeline, opposite retention policies, each on purpose.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~1500px wide at 0.6 JPEG is plenty.&lt;/strong&gt; Claude reads supermarket thermal print fine at that size, and it keeps upload plus vision latency tolerable on a phone connection.&lt;/p&gt;

&lt;p&gt;This was also the feature where reload-only development ended: &lt;code&gt;expo-image-picker&lt;/code&gt; and &lt;code&gt;expo-image-manipulator&lt;/code&gt; are native modules with config-plugin permission strings, so the first camera feature cost a full EAS dev-client rebuild before it could be tested at all. Budget that before you demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt is where the intelligence lives
&lt;/h2&gt;

&lt;p&gt;The Lambda is 71 lines and most of the intelligence is English. Excerpts from the real system prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;price is the &lt;strong&gt;UNIT price&lt;/strong&gt; in the receipt's currency (a line's total divided by its quantity) …&lt;/li&gt;
&lt;li&gt;If a line is priced by weight (e.g. "0.62 kg @ £1.50/kg"), set quantity to the weight and unit to the measure ("kg").&lt;/li&gt;
&lt;li&gt;Skip non-item lines: subtotal, tax/VAT, total, change, tendered, card, loyalty/points …&lt;/li&gt;
&lt;li&gt;These receipts are UK, so read ambiguous numeric dates &lt;strong&gt;DAY-FIRST&lt;/strong&gt;: "05/07/2026" is 5 July 2026, not 7 May.&lt;/li&gt;
&lt;li&gt;Receipts rarely print the currency code, so &lt;strong&gt;infer&lt;/strong&gt; it: from the symbol (£=GBP, €=EUR …), from the retailer, or from the tax wording ("VAT" + £ = GBP, "MwSt" = EUR, "Sales Tax" + $ = USD). Only use null when there is genuinely no signal.&lt;/li&gt;
&lt;li&gt;If the image is not a readable receipt, return &lt;code&gt;{ "items": [], "store": null, … }&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three of those earn their keep daily. The &lt;strong&gt;day-first rule&lt;/strong&gt; exists because without it the model reads dates US-style and a July shop lands in May. The &lt;strong&gt;currency inference&lt;/strong&gt; exists because receipts almost never print an ISO code — but they're full of indirect signals. And the &lt;strong&gt;typed empty result&lt;/strong&gt; is the anti-hallucination valve: given a legal way to say "this isn't a receipt," the model stops inventing groceries from a photo of your cat.&lt;/p&gt;

&lt;p&gt;The handler around it is defensive plumbing: strip the code fences the prompt already forbade, &lt;code&gt;JSON.parse&lt;/code&gt;, throw with a truncated sample if that fails, and return &lt;code&gt;JSON.stringify(parsed)&lt;/code&gt; — an &lt;code&gt;a.json()&lt;/code&gt; custom mutation returns an AWSJSON &lt;em&gt;string&lt;/em&gt;. Remember that last sentence; it comes back with a knife.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extraction in the Lambda, matching on the client
&lt;/h2&gt;

&lt;p&gt;The Lambda never sees your shopping list. It extracts; the client — which already holds the list in cache — matches. Each extracted line is normalised (same &lt;code&gt;frequentKey&lt;/code&gt; normalisation the frequently-bought feature uses) and compared against &lt;strong&gt;unchecked&lt;/strong&gt; items on the active list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match&lt;/strong&gt; → the item gets the price and a tick: &lt;code&gt;updateItem({ price, checked: true })&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No match&lt;/strong&gt; → a new, already-bought item: added priced &lt;em&gt;and&lt;/em&gt; checked, straight to Done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are the &lt;a href="https://dev.to/hokagedemehin/working-offline-in-the-cereal-aisle-tanstack-query-offline-on-amplify-5cn0"&gt;keyed offline mutations from Part 7&lt;/a&gt;, so applying a receipt inherits optimistic updates and offline replay without any new plumbing — same dividend as Part 13, one feature later.&lt;/p&gt;

&lt;p&gt;The review sheet is the gate before any of that runs: every line editable (price, quantity), removable, with a match badge — &lt;em&gt;"prices **Milk&lt;/em&gt;* on your list"* vs &lt;em&gt;"new item"&lt;/em&gt; — and a &lt;strong&gt;receipt-total vs items-sum reconciliation line&lt;/strong&gt; at the bottom. If Claude missed a line, the two numbers disagree and you can see it; a silent gap is the one thing a money feature can't afford.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvx5alndr0bi54e6tfvp4.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvx5alndr0bi54e6tfvp4.jpeg" alt="The review sheet: extracted lines with match badges and editable prices, reconciled against the receipt's printed total" width="414" height="1280"&gt;&lt;/a&gt; Later, a persisted preference (&lt;code&gt;addUnmatchedItems&lt;/code&gt;) made the "add new items" half optional — turn it off and a receipt is still recorded in full, it just doesn't touch your list.&lt;/p&gt;

&lt;h2&gt;
  
  
  War story #1: the AccessDenied that looked impossible
&lt;/h2&gt;

&lt;p&gt;First upload: &lt;code&gt;AccessDenied&lt;/code&gt;. The storage rule was textbook — an owner-scoped path, authenticated read/write on &lt;code&gt;receipts/{entity_id}/*&lt;/code&gt;. The kind of rule you copy from the docs and never think about again.&lt;/p&gt;

&lt;p&gt;The cause took a while to see because it lives in a different service. &lt;code&gt;allow.entity("identity")&lt;/code&gt; policies the &lt;strong&gt;default authenticated role&lt;/strong&gt;. But this app puts every user in a Cognito group (&lt;code&gt;USERS&lt;/code&gt;), and a user in a group assumes the &lt;strong&gt;group's role&lt;/strong&gt; instead. Correct-looking storage rule, wrong role — every upload denied.&lt;/p&gt;

&lt;p&gt;The fix is a ManagedPolicy granting the group roles access to the owner-scoped prefix — and &lt;em&gt;where&lt;/em&gt; it lives matters. It sits in the &lt;strong&gt;storage stack&lt;/strong&gt;, listing the auth stack's group roles: storage already depends on auth, so the dependency stays one-way. Attach it from the auth side instead (auth → storage) and you've built a circular dependency; the deploy fails. The policy is still named &lt;code&gt;GroupReceiptOwnerAccess&lt;/code&gt; — this feature's scar, now a house convention applied to every owner-scoped prefix since.&lt;/p&gt;

&lt;h2&gt;
  
  
  War story #2: the save that failed silently, all session
&lt;/h2&gt;

&lt;p&gt;Receipts were parsing beautifully, the review sheet applied prices, everything looked done — and no &lt;code&gt;Receipt&lt;/code&gt; row ever existed. No error, no red screen, nothing. For a whole session the feature reported success while writing nothing.&lt;/p&gt;

&lt;p&gt;Two Amplify behaviors conspired. First: &lt;strong&gt;&lt;code&gt;a.json()&lt;/code&gt; fields take a JSON *string&lt;/strong&gt;* — and the code was passing &lt;code&gt;lineItems&lt;/code&gt; as a raw array, which failed AppSync validation. Second, the one that made it invisible: &lt;strong&gt;Amplify's client doesn't throw&lt;/strong&gt; — it returns &lt;code&gt;{ data, errors }&lt;/code&gt;, and a mutation with no &lt;code&gt;onError&lt;/code&gt; and an unchecked &lt;code&gt;errors&lt;/code&gt; just… proceeds. &lt;code&gt;data&lt;/code&gt; was null, &lt;code&gt;errors&lt;/code&gt; had the validation failure, and nobody was looking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The shape of the fix — every Amplify call now checks errors and throws:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
  &lt;span class="na"&gt;lineItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// a.json() wants a STRING, not an array&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write-side rule (stringify) and the read-side rule (tolerate string-or-parsed) are now conventions, and surfacing &lt;code&gt;errors&lt;/code&gt; is non-negotiable. Learn your client's error contract before it teaches you: a client that returns failures instead of throwing them turns every unchecked call site into a silent-failure factory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;Receipt&lt;/code&gt; model: the first real new kind
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/hokagedemehin/a-template-is-just-a-list-with-a-flag-reusable-shopping-lists-20nb"&gt;Part 11&lt;/a&gt; and &lt;a href="https://dev.to/hokagedemehin/the-expenses-tab-mvp-prices-ride-on-the-items-you-already-have-18fn"&gt;Part 13&lt;/a&gt; kept refusing new models — a template is a flag, an expense is an attribute. A scanned receipt finally &lt;em&gt;passes&lt;/em&gt; the new-kind test, for one reason: &lt;strong&gt;a receipt is an immutable record, and list items are live state.&lt;/strong&gt; The items a receipt priced keep changing — renamed, re-priced, cleared. The receipt must not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Receipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;listName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;   &lt;span class="c1"&gt;// denormalised — the /receipts screen spans lists&lt;/span&gt;
  &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;imageKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// receipts/{identityId}/&amp;lt;uuid&amp;gt;.jpg — the kept photo&lt;/span&gt;
  &lt;span class="na"&gt;lineItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;    &lt;span class="c1"&gt;// snapshot [{name,quantity,unit,price,category,matchedName}]&lt;/span&gt;
  &lt;span class="na"&gt;itemsTotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  &lt;span class="c1"&gt;// sum of the lines we logged&lt;/span&gt;
  &lt;span class="na"&gt;storedTotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// the receipt's own printed grand total&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;capturedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="c1"&gt;// The date PRINTED on the receipt, kept separate from capturedAt — you might&lt;/span&gt;
  &lt;span class="c1"&gt;// scan a week later, and spend analytics must bucket by when you shopped.&lt;/span&gt;
  &lt;span class="na"&gt;purchaseDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// date-only: a timezone can't drift it into the wrong day&lt;/span&gt;
  &lt;span class="c1"&gt;// … soft-delete trio&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields carry the design. &lt;code&gt;lineItems&lt;/code&gt; is a &lt;strong&gt;frozen JSON snapshot&lt;/strong&gt; — the record of what Claude read, independent of what the live items later become. And &lt;code&gt;purchaseDate&lt;/code&gt; is an &lt;strong&gt;&lt;code&gt;AWSDate&lt;/code&gt;, separate from &lt;code&gt;capturedAt&lt;/code&gt;&lt;/strong&gt;: you scan when you get home (or three days later), analytics must bucket by when you &lt;em&gt;shopped&lt;/em&gt;, and date-only means no timezone math can ever shift a Saturday shop into Friday.&lt;/p&gt;

&lt;p&gt;Receipt history then mounts in &lt;strong&gt;two stacks&lt;/strong&gt; — &lt;code&gt;/expenses/receipts&lt;/code&gt; and &lt;code&gt;/more/receipts&lt;/code&gt; — as thin routes around one shared screen, so neither tab ever jumps you into the other. Month subtotals stay &lt;strong&gt;per currency&lt;/strong&gt;; a receipt in euros is flagged and excluded from GBP budget maths rather than blended into a confident, meaningless number.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frv1zejjhyrnm5ovm98nt.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frv1zejjhyrnm5ovm98nt.jpeg" alt="Receipt history: scanned receipts grouped by month, each with its store, total and line count" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Draw the boundaries first&lt;/strong&gt;: capture on the client, extraction in the Lambda, matching on the client, confirmation with the user, immutability in the record. Every piece got simpler once its job was singular.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain rules belong in the prompt&lt;/strong&gt; — day-first dates, currency inference, and a typed way to say "not a receipt" outperform any post-processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owner-scoped storage rules don't cover group roles.&lt;/strong&gt; If your users are in Cognito groups, they aren't the role your storage rule policied — grant the group roles from the storage side, or enjoy the circular dependency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;{ data, errors }&lt;/code&gt; client is a silent-failure factory&lt;/strong&gt; until you check &lt;code&gt;errors&lt;/code&gt; everywhere — and &lt;code&gt;a.json()&lt;/code&gt; takes a string, not your array.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Records and live rows are different kinds.&lt;/strong&gt; The snapshot/live split (plus a separate printed-date field) is what keeps the analytics honest later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Receipts price your history; the next input device prices your &lt;em&gt;present&lt;/em&gt;: the barcode scanner. A lookup that walks a provider chain across four open product databases, uses Claude as a &lt;strong&gt;normaliser&lt;/strong&gt; (never a generator), and caches every answer in a product table shared by all users — Post 15.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's your policy for LLM output that writes to the database — review gate, confidence threshold, or straight through? And has a silent &lt;code&gt;{ data, errors }&lt;/code&gt;-style client burned you too?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>aws</category>
      <category>ai</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The Expenses tab MVP: prices ride on the items you already have</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sat, 22 Aug 2026 14:37:32 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/the-expenses-tab-mvp-prices-ride-on-the-items-you-already-have-18fn</link>
      <guid>https://dev.to/hokagedemehin/the-expenses-tab-mvp-prices-ride-on-the-items-you-already-have-18fn</guid>
      <description>&lt;p&gt;The app is called *Canny*Cart, and for its first few weeks it had no idea what anything cost. The shopping tab could capture a list by voice, work offline in the aisle, and remember your usual milk — but the money stayed in your head, which is exactly where money is worst at staying.&lt;/p&gt;

&lt;p&gt;The obvious fix is an &lt;code&gt;Expense&lt;/code&gt; model: a transactions table, categories, maybe a sync job keeping it consistent with the list. The shipped fix is &lt;strong&gt;one line of schema&lt;/strong&gt; — because the expense rows already existed. They're the shopping items.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — In a shopping app, an "expense" is an &lt;em&gt;attribute of an item you already have&lt;/em&gt;, not a new noun. Fix the price semantics first (unit price × quantity), sum in integer pennies, make price entry ride the existing offline mutation so it works with no signal, color the budget bar with semantic tokens (never the brand accent), and show "N unpriced" next to the total instead of silently treating unpriced as £0. The whole tab: one new field, 15 files, +545 lines.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 13 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork: an Expense model vs an attribute
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/hokagedemehin/a-template-is-just-a-list-with-a-flag-reusable-shopping-lists-20nb"&gt;Part 11&lt;/a&gt; had a rule I keep re-using: before adding a model, check whether the new thing is really a new &lt;em&gt;kind&lt;/em&gt; or just a new &lt;em&gt;fact about an existing kind&lt;/em&gt;. A grocery expense fails the new-kind test hard. It has the same owner, the same lifecycle, the same aisle, the same name as the shopping item it describes — a parallel &lt;code&gt;Expense&lt;/code&gt; table would spend its whole life being synced against the list it mirrors.&lt;/p&gt;

&lt;p&gt;So: &lt;code&gt;ShoppingItem.price&lt;/code&gt; (a field that already existed, waiting for semantics) plus exactly one new line —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ShoppingList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
  &lt;span class="na"&gt;isTemplate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// per-list spend budget (GBP) — Expenses tab&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ShoppingItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;listId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Additive field → no table replacement, no migration. The whole commit: 15 files, +545/−1, one schema line. (A &lt;code&gt;Receipt&lt;/code&gt; model &lt;em&gt;did&lt;/em&gt; arrive later — when photographed receipts introduced a genuinely new kind: an immutable record of a transaction. That's the next post. The tab didn't need it to ship.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide the price semantics before the UI
&lt;/h2&gt;

&lt;p&gt;The plan doc locked this before any code: &lt;strong&gt;price is the unit price&lt;/strong&gt;, and the line total is &lt;code&gt;price × (quantity ?? 1)&lt;/code&gt;. Because that's what shelf labels say — milk is "£1.30", not "£2.60 if you buy two" — and it means changing a quantity moves the money without touching the price. Weighed items fall out of the same rule loosely: quantity &lt;code&gt;0.5&lt;/code&gt;, unit &lt;code&gt;kg&lt;/code&gt;, price per kg.&lt;/p&gt;

&lt;p&gt;It's a two-minute decision on paper and a miserable retrofit once prices are stored ambiguously. Cheap decisions early; expensive ones never.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sum in pennies
&lt;/h2&gt;

&lt;p&gt;Floating-point money drifts — &lt;code&gt;0.1 + 0.2&lt;/code&gt; territory, multiplied across a 30-item list. The helpers sum in whole pennies and format at the edge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// GBP money helpers. Sum in whole pennies to avoid float drift, format at the edge.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toPennies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="cm"&gt;/** Line total in pennies: unit price × quantity, rounded to the penny. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linePennies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toPennies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formatMoney&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`£&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine lines. The number of production money bugs those nine lines prevent is not nine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture in both places, one mutation — and offline for free
&lt;/h2&gt;

&lt;p&gt;Prices go in wherever you happen to be: a compact &lt;code&gt;＋ £&lt;/code&gt; affordance on the shopping row (price as you shop), or the editable list in the Expenses tab. Both open the same 122-line &lt;code&gt;AmountSheet&lt;/code&gt; (a numeric £ entry that budget editing reuses too), and both end in the same call: &lt;code&gt;updateItem({ price })&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fplyqd8ouc93pmfnjuup1.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fplyqd8ouc93pmfnjuup1.jpeg" alt="The AmountSheet: a minimal numeric price entry opened from a shopping row" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That one sentence is the payoff of &lt;a href="https://dev.to/hokagedemehin/working-offline-in-the-cereal-aisle-tanstack-query-offline-on-amplify-5cn0"&gt;Part 7's offline architecture&lt;/a&gt;. &lt;code&gt;updateItem&lt;/code&gt; is a &lt;strong&gt;keyed offline mutation default&lt;/strong&gt; — optimistic, persisted, replayable after an app kill — so price entry works in a concrete supermarket basement with zero bars, &lt;em&gt;with zero new plumbing&lt;/em&gt;. Adding the feature was adding &lt;code&gt;price&lt;/code&gt; to the mutation's variables type. Setting a budget joined the same system as one more keyed default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMutationDefaults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setBudget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;SetBudgetVars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onMutate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// optimistic patch of the cached lists; rollback + invalidate as usual&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Architecture you paid for once, paying you back per feature. This is the first feature where that stopped being a theory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tab is a pure selector over the cache
&lt;/h2&gt;

&lt;p&gt;There's no fetch in the Expenses tab. &lt;code&gt;useExpenses&lt;/code&gt; computes everything from the items React Query already holds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalPennies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;priced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;linePennies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BudgetStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;over&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;near&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// pennies per category&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;priced&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Other&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;linePennies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pure function of cached data ⇒ the whole tab works offline by construction. The by-aisle breakdown then renders in the user's own saved aisle order — the ordering system from &lt;a href="https://dev.to/hokagedemehin/whose-categories-are-these-making-an-llms-throwaway-labels-feel-like-real-things-4bjg"&gt;Part 9&lt;/a&gt; getting reused instead of reinvented.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftb5g8u43pf8ngh0f1868.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftb5g8u43pf8ngh0f1868.jpeg" alt="The Expenses tab: running total against the budget bar, spend by aisle, and the unpriced count" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Semantic colors, and the accent that isn't there
&lt;/h2&gt;

&lt;p&gt;The budget bar is &lt;strong&gt;green&lt;/strong&gt; under 80%, &lt;strong&gt;amber&lt;/strong&gt; 80–100%, &lt;strong&gt;red&lt;/strong&gt; over — the design system's semantic trio. What it never uses is coral, the brand's money-moment accent. That's deliberate discipline, not oversight: in this design system red means &lt;em&gt;over budget&lt;/em&gt;, green means &lt;em&gt;under/cheapest&lt;/em&gt;, and coral is reserved exclusively for &lt;em&gt;savings&lt;/em&gt; — a feature this post doesn't build. An accent you spend everywhere is an accent you no longer have. (Coral gets its moment in a later post, and it's worth the wait.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The honesty note
&lt;/h2&gt;

&lt;p&gt;Next to the total: &lt;em&gt;"3 items without a price."&lt;/em&gt; Unpriced items are excluded from the sum and &lt;strong&gt;counted out loud&lt;/strong&gt; — never silently treated as £0, which would make the total confidently wrong. This became a small recurring principle across the app: a number you can't trust is worse than no number. The total earns trust by admitting what it doesn't know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the tab got a haircut
&lt;/h2&gt;

&lt;p&gt;Two follow-up commits taught the layout lesson. First the summary was compacted to a &lt;strong&gt;constant height&lt;/strong&gt;: spend-by-aisle shows the top 4 (the list is already sorted by spend), the price-me section lists only unpriced items capped at 4, and an "everything's priced" state replaces the section instead of leaving a hole. Then the full, growing list moved to its own screen — &lt;code&gt;/expenses/items&lt;/code&gt;, with filter chips and a debounced search (this commit is where the shared &lt;code&gt;useDebouncedValue&lt;/code&gt; hook was born; every search input since uses it).&lt;/p&gt;

&lt;p&gt;The rule that fell out: &lt;strong&gt;a tab is a summary; a list is a screen.&lt;/strong&gt; Whatever accumulates length needs a dedicated scrolling surface — a dashboard that grows with your data slowly stops being a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits — each one is a future post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GBP is hardcoded.&lt;/strong&gt; &lt;code&gt;money.ts&lt;/code&gt; literally templates a &lt;code&gt;£&lt;/code&gt;. Fine for a single-region MVP; it fell soon after, and the audit of everywhere a currency symbol hides is its own story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The budget is per-list.&lt;/strong&gt; "This shop: £30" — but months are how people actually budget, and moving budgets from lists onto months later became a genuinely interesting data-model rework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No receipts.&lt;/strong&gt; Prices go in by hand. The camera was about to change that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the post where the money arc opens; those three limits are the arc.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check the new-kind test before adding a model.&lt;/strong&gt; An expense here is a fact about a shopping item — one field beats a parallel table you'll sync forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix semantics before UI.&lt;/strong&gt; Unit price × quantity was a plan-doc decision; retrofitting ambiguous stored prices would have been a migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integer pennies, format at the edge.&lt;/strong&gt; Nine lines against a whole class of drift bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Good architecture pays per feature.&lt;/strong&gt; Offline price capture cost nothing because Part 7 already built the rails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbers must confess.&lt;/strong&gt; Count the unpriced; never silently assume £0.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;The by-hand part is the weak link — after a real shop, nobody prices 30 items manually. Next post: &lt;strong&gt;point the camera at the paper receipt and let Claude read it&lt;/strong&gt; — the vision Lambda, the review sheet that matches receipt lines to list items, and the day a receipt saved silently failed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where do you draw the line between "attribute on an existing model" and "new table" for money data? I keep getting away with the attribute — tell me when it finally bites.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>aws</category>
      <category>mobile</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The polish pass: smart add, pull-to-refresh, sticky headers, tighter archive</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:32:01 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/the-polish-pass-smart-add-pull-to-refresh-sticky-headers-tighter-archive-2bnn</link>
      <guid>https://dev.to/hokagedemehin/the-polish-pass-smart-add-pull-to-refresh-sticky-headers-tighter-archive-2bnn</guid>
      <description>&lt;p&gt;Nothing in this post is bigger than a few dozen lines. There's a 12-line parser, a 22-line hook, a one-prop fix, and a bug whose entire cure was &lt;em&gt;deleting&lt;/em&gt; a component. None of them is a feature. Nobody will ever screenshot them for a launch tweet.&lt;/p&gt;

&lt;p&gt;But the gap between "works in the demo" and "feels like an app" is made of exactly this — and I shipped it as one deliberate &lt;strong&gt;polish pass&lt;/strong&gt;: three commits, ~850 lines, one afternoon that ran past midnight.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Batch your polish instead of fixing papercuts drive-by: you're already in the files, and small changes gain momentum together. Along the way: typed input gets a &lt;strong&gt;regex, not an LLM&lt;/strong&gt; ("2 milk" → Milk ×2); re-adding an item &lt;strong&gt;merges instead of duplicating&lt;/strong&gt;; "clear completed" is a bulk soft-delete; pull-to-refresh is a reusable React Query hook; &lt;code&gt;stickySectionHeadersEnabled&lt;/code&gt; is quietly &lt;strong&gt;iOS-only by default&lt;/strong&gt;; and a keyboard-dismiss wrapper was eating Android's first scroll touch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 12 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a "pass" and not drive-by fixes
&lt;/h2&gt;

&lt;p&gt;Every one of these annoyances had been on a list for days. I could have fixed each the moment I noticed it — mid-feature, context-switching every time. Instead they queued up until there was a batch worth doing, and then the batch went fast: the second fix in a file costs a fraction of the first, because you're already in there with the file's model in your head. Polish also compounds &lt;em&gt;perceptually&lt;/em&gt; — one fixed papercut is invisible, six at once makes the app suddenly feel finished.&lt;/p&gt;

&lt;p&gt;Here's the montage.&lt;/p&gt;

&lt;h2&gt;
  
  
  "2 milk" → Milk ×2 — a regex, not an LLM
&lt;/h2&gt;

&lt;p&gt;The app's whole party trick is &lt;a href="https://dev.to/hokagedemehin/milk-and-a-dozen-eggs-a-voice-to-claude-shopping-list-that-never-trusts-the-ai-mi4"&gt;voice input parsed by Claude&lt;/a&gt; — rambling speech in, structured items out. So when I added quantity support to the &lt;em&gt;typed&lt;/em&gt; capture bar, there was a lazy option sitting right there: send typed text through the same Lambda.&lt;/p&gt;

&lt;p&gt;No. Typed input isn't rambling speech — it's a pattern you can write down. The entire parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\w&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="cm"&gt;/** "2 milk" → { name: "Milk", quantity: 2 }; anything else → quantity 1. Local, no Claude. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseManualItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d{1,2})\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;qty&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve lines, zero latency, zero cost, works offline. &lt;strong&gt;An LLM is for ambiguity; a regex is for a pattern.&lt;/strong&gt; Reaching for the model here would have added a network round-trip to every keystroke-and-enter — to do a worse job than &lt;code&gt;^(\d{1,2})\s+(.+)$&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the add itself got one more brain cell: if an unchecked item with the same name is already on the list, don't spawn a duplicate row — bump its quantity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checked&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;updateItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;addItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type "2 milk" when Milk ×1 is already there and you get Milk ×3, not two milk rows arguing with each other. Note the &lt;code&gt;!i.checked&lt;/code&gt; — if you already bought milk this trip, a new "milk" is a &lt;em&gt;new&lt;/em&gt; need, not an increment of the done one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clear completed — a bulk delete that isn't a delete
&lt;/h2&gt;

&lt;p&gt;A finished shop leaves a graveyard of checked items. One "Clear completed" action now sweeps them — behind a destructive-confirm dialog, because it's a bulk operation on the user's data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bulk "clear completed": soft-delete every checked item at once.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clearChecked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;currentUserId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;_deleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;_deletedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;_deletedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the house soft-delete convention — flip flags, never &lt;code&gt;.delete()&lt;/code&gt; — doing quiet double duty: those "cleared" rows are precisely the purchase history that powers the &lt;a href="https://dev.to/hokagedemehin/you-cant-group-by-in-dynamodb-so-the-client-does-the-counting-4dnd"&gt;frequently-bought chips from Part 10&lt;/a&gt;. Clearing your list &lt;em&gt;feeds the feature that refills it&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tap to edit, and an archive that behaves
&lt;/h2&gt;

&lt;p&gt;Two structural touches, no code excerpts needed. Items became &lt;strong&gt;tappable&lt;/strong&gt;: a row now opens an edit sheet with the full field set — name, quantity, unit, category, note — so a mis-parse or a changed mind doesn't mean delete-and-retype.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F49tz6izdct59csmvig4o.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F49tz6izdct59csmvig4o.jpeg" alt="The edit-item sheet: tap any row to change its name, quantity, unit, category, or note" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And archiving got tightened into an actual lifecycle: archived lists leave the switcher for a restorable &lt;strong&gt;Archived&lt;/strong&gt; section (one tap to bring one back), and the active-list picker learned to &lt;em&gt;never&lt;/em&gt; settle on an archived list — previously it could quietly select one after a sync, which is exactly the kind of state you only hit on a Tuesday in the store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pull-to-refresh, in brand colors
&lt;/h2&gt;

&lt;p&gt;The app is offline-first with a persisted cache, so screens don't refetch on focus — which means users need a manual "give me the latest" gesture. Pull-to-refresh is the muscle-memory answer, and it's two tiny reusable pieces. A hook that refetches whichever React Query keys a screen cares about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;usePullRefresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queryKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[])[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;refreshing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRefreshing&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onRefresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setRefreshing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;queryKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;qc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refetchQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setRefreshing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;qc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryKeys&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;refreshing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onRefresh&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and a &lt;code&gt;BrandRefreshControl&lt;/code&gt; that tints the spinner teal in light mode and the brighter dark-mode teal after sunset, so even the loading indicator is on the design system. Forty-five lines total, already used on two screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Android ghosts
&lt;/h2&gt;

&lt;p&gt;Both of the actual &lt;em&gt;bugs&lt;/em&gt; in this pass were Android-shaped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sticky section headers are an iOS-only default.&lt;/strong&gt; My aisle headers ("🥦 PRODUCE") pinned while scrolling on iOS and just… scrolled away on Android. The reason is one of RN's quieter platform splits: &lt;code&gt;SectionList&lt;/code&gt; sticks headers by default on iOS only. Opting in is one prop — but the moment a header actually pins, you discover the second half of the bug: a transparent header floats &lt;em&gt;over&lt;/em&gt; the items scrolling behind it, text bleeding through text. The fix is giving the header an opaque page-colored band, and moving the section's top spacing from margin (outside the background) to padding (inside it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; &amp;lt;SectionList
   sections={sections}
&lt;span class="gi"&gt;+  stickySectionHeadersEnabled
&lt;/span&gt;   ...
&lt;span class="gd"&gt;-  &amp;lt;Text className="mb-1 ml-1.5 mt-3 font-nunito-bold ..."&amp;gt;
-    {section.emoji} {section.displayName}
-  &amp;lt;/Text&amp;gt;
&lt;/span&gt;&lt;span class="gi"&gt;+  &amp;lt;View className="bg-page pb-1 pt-3 dark:bg-page-dark"&amp;gt;
+    &amp;lt;Text className="ml-1.5 font-nunito-bold ..."&amp;gt;
+      {section.emoji} {section.displayName}
+    &amp;lt;/Text&amp;gt;
+  &amp;lt;/View&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven lines added, and the list finally scrolls like the ones in apps you pay for:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo8a1slyqirfphwj47py5.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo8a1slyqirfphwj47py5.jpeg" alt="Mid-scroll on Android: the aisle header pinned at the top on its opaque band while items scroll cleanly behind it" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The keyboard-dismiss wrapper was stealing first touches.&lt;/strong&gt; When the capture bar first landed, the whole screen got wrapped in &lt;code&gt;TouchableWithoutFeedback onPress={Keyboard.dismiss}&lt;/code&gt; — the standard trick, because React Native doesn't blur inputs when you tap outside them. On Android it had a side effect I'd been blaming on everything else: with the keyboard up, the &lt;strong&gt;first&lt;/strong&gt; scroll gesture on the list did nothing — swallowed by the tap-catcher deciding whether that touch was a press. The fix was pure deletion: remove the wrapper, dismiss the keyboard at explicit moments (submitting, opening a sheet) instead of ambiently. The best kind of bug fix is negative lines. Every screen-wide convenience wrapper is a gesture-thief suspect until proven innocent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Batch polish into a pass.&lt;/strong&gt; Papercuts queue cheaply and fix cheaply together; the second change in a file is nearly free, and six fixes at once move the "feels finished" needle in a way one never does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size the tool to the job.&lt;/strong&gt; Voice gets Claude; "2 milk" gets a 12-line regex. If the input is a pattern, parse the pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge beats duplicate&lt;/strong&gt; — but only against &lt;em&gt;unchecked&lt;/em&gt; items; done items are history, not state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never trust a cross-platform default until you've watched Android.&lt;/strong&gt; Sticky headers, gesture handling, keyboard behavior — the splits are quiet and the docs whisper them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer fixes that delete code.&lt;/strong&gt; The scroll bug's cure was removing a component, not adding a workaround.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shopping tab is done — next arc: money
&lt;/h2&gt;

&lt;p&gt;That closes the arc this series has been building since the &lt;a href="https://dev.to/hokagedemehin/milk-and-a-dozen-eggs-a-voice-to-claude-shopping-list-that-never-trusts-the-ai-mi4"&gt;voice pipeline in Part 5&lt;/a&gt;: capture (voice &lt;em&gt;and&lt;/em&gt; typed), offline-first sync, aisle ordering, frequently-bought chips, templates, &lt;a href="https://dev.to/hokagedemehin/i-deleted-the-drag-and-drop-library-and-shipped-four-buttons-instead-3ce1"&gt;reordering that survived a library deletion&lt;/a&gt;, and now the polish. A shopping list I genuinely use in the shop every week.&lt;/p&gt;

&lt;p&gt;The next arc is &lt;strong&gt;money&lt;/strong&gt;: what things cost, per-list budgets, a spend breakdown — and pointing the phone's camera at a paper receipt and letting Claude read it. That's already under construction, and it comes with its own war stories.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's in your polish backlog right now — and what's actually stopping you from burning it down in one pass? Tell me the papercut you've been ignoring longest.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>ux</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>A template is just a list with a flag: reusable shopping lists</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Wed, 22 Jul 2026 12:04:50 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/a-template-is-just-a-list-with-a-flag-reusable-shopping-lists-20nb</link>
      <guid>https://dev.to/hokagedemehin/a-template-is-just-a-list-with-a-flag-reusable-shopping-lists-20nb</guid>
      <description>&lt;p&gt;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: &lt;strong&gt;"Weekly essentials," saved once, spun up fresh in one tap.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What's less obvious is what that feature costs. I went in braced for a &lt;code&gt;Template&lt;/code&gt; 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 &lt;strong&gt;one new schema field and zero new tables&lt;/strong&gt;. The design work wasn't building a template system. It was realising there shouldn't be one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A reusable template is the same &lt;code&gt;ShoppingList&lt;/code&gt; model with &lt;code&gt;isTemplate: a.boolean()&lt;/code&gt; on it. &lt;em&gt;Kind&lt;/em&gt; is orthogonal to &lt;em&gt;lifecycle&lt;/em&gt;, so it gets its own field instead of a new model or a new &lt;code&gt;status&lt;/code&gt; value. One copy helper runs in both directions (list → template, template → list). The real engineering wasn't the flag — it was the &lt;strong&gt;three ripple effects&lt;/strong&gt;: every piece of code that enumerates lists had to learn what the flag means, or quietly break.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 11 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork: a new model, a new status, or a flag
&lt;/h2&gt;

&lt;p&gt;The app already has a &lt;code&gt;ShoppingList&lt;/code&gt; model with a lifecycle enum — &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;ARCHIVED&lt;/code&gt;, &lt;code&gt;COMPLETED&lt;/code&gt; — and &lt;code&gt;ShoppingItem&lt;/code&gt; rows hanging off it. Three ways to represent "template":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;Template&lt;/code&gt; model.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;"TEMPLATE"&lt;/code&gt; status.&lt;/strong&gt; Tempting — the enum is &lt;em&gt;right there&lt;/em&gt;. But &lt;code&gt;status&lt;/code&gt; answers "where is this list in its life?", and template-ness answers "what &lt;em&gt;kind&lt;/em&gt; 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 &lt;code&gt;status === "ACTIVE"&lt;/code&gt; check in the app silently needs a special case it doesn't know it needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A boolean.&lt;/strong&gt; Kind is orthogonal to lifecycle, so it gets an orthogonal field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ShoppingList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ACTIVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ARCHIVED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMPLETED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;isTemplate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// true = reusable template, hidden from the switcher&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ShoppingItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;listId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// … soft-delete trio&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire backend diff — one line. And because it's an &lt;strong&gt;additive&lt;/strong&gt; field, DynamoDB just… accepts it. No table replacement, no migration. (This matters more than it sounds: &lt;a href="https://dev.to/hokagedemehin/my-main-branch-wouldnt-deploy-cloudformation-forensics-on-amplify-gen-2-380d"&gt;in Part 4&lt;/a&gt; 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.)&lt;/p&gt;

&lt;p&gt;The commit for the whole feature: 8 files, +268/−28. One of those 268 lines is schema; the rest is client.&lt;/p&gt;

&lt;h2&gt;
  
  
  A template is storage, not machinery
&lt;/h2&gt;

&lt;p&gt;Because a template &lt;em&gt;is&lt;/em&gt; a &lt;code&gt;ShoppingList&lt;/code&gt;, its items are ordinary &lt;code&gt;ShoppingItem&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The copy helper is the only genuinely new logic, and it runs in both directions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Copy a list's live items into another list, reset to unchecked with fresh&lt;/span&gt;
&lt;span class="c1"&gt;// positions. Shared by save-as-template and instantiate-template.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;copyItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceListId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetListId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;and&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sourceListId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;NOT_DELETED_FILTER&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;targetListId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;note&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;sourceText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceText&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save-as-template snapshots the live list &lt;em&gt;into&lt;/em&gt; a flagged list; "Use" copies a flagged list &lt;em&gt;out&lt;/em&gt; into a fresh active one. Same loop, opposite directions (excerpt — error handling elided):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saveAsTemplate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sourceListId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tpl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ACTIVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isTemplate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;copyItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceListId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instantiateTemplate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ACTIVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isTemplate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;copyItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest divergence from my own plan: the plan said save-as-template should copy only the &lt;em&gt;unchecked&lt;/em&gt; items ("what's still outstanding"). The shipped version copies &lt;strong&gt;everything, reset to unchecked&lt;/strong&gt; — because halfway through building it I realised a template is a &lt;em&gt;set&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjuu1xr11fgynne0kb57i.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjuu1xr11fgynne0kb57i.jpeg" alt="The lists sheet with " width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One flag, three ripples
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes "just add a boolean" worth a blog post: &lt;strong&gt;every piece of code that enumerates lists now has an opinion about templates&lt;/strong&gt;, whether it knows it or not. I found three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The switcher must partition.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allLists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useShoppingLists&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Templates live alongside real lists but never appear in the switcher.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allLists&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isTemplate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;allLists&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;templates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allLists&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isTemplate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;allLists&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sheet renders them as their own section — each with a &lt;strong&gt;Use&lt;/strong&gt; button and a delete — below Archived:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufbn72lhmdfpumkoqrjm.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufbn72lhmdfpumkoqrjm.jpeg" alt="The lists sheet's Templates section: each saved template with its Use button and delete, below the active and archived lists" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The "first list" guard must mean &lt;em&gt;real&lt;/em&gt; lists.&lt;/strong&gt; 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 &lt;em&gt;has&lt;/em&gt; rows in the table but &lt;em&gt;has no list to shop with&lt;/em&gt;; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The frequency counter must exclude template items.&lt;/strong&gt; &lt;a href="https://dev.to/hokagedemehin/you-cant-group-by-in-dynamodb-so-the-client-does-the-counting-4dnd"&gt;Last post&lt;/a&gt; built "frequently bought" chips by counting the user's item history client-side. A template's items are a &lt;em&gt;saved set&lt;/em&gt;, not a &lt;em&gt;shopping occasion&lt;/em&gt; — 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Names, dates, and deletes
&lt;/h2&gt;

&lt;p&gt;Two small decisions I'd flagged as open questions in the plan, resolved by picking the lowest-friction option:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instantiate naming is zero-prompt.&lt;/strong&gt; Tapping &lt;strong&gt;Use&lt;/strong&gt; doesn't ask you to name anything — it stamps the template name with today's date and switches you to the new list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;instantiateTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; · &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;shortDate&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// "Weekly essentials · 22 Jul"&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A naming prompt is exactly the friction this feature exists to remove. You can rename later; you almost never will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template delete is a soft delete.&lt;/strong&gt; Like every "delete" in this app, removing a template flips &lt;code&gt;_deleted&lt;/code&gt; flags — never a real &lt;code&gt;.delete()&lt;/code&gt;. Which, as the last post found out, occasionally pays unexpected dividends: history you didn't destroy is history you can mine later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits, and the phase 2 I'm not building
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;copyItems&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;And the obvious next step is deliberately &lt;em&gt;not built&lt;/em&gt;: &lt;strong&gt;scheduled recurrence.&lt;/strong&gt; "Every Monday, create this week's list from the template and ping me" needs real machinery — a cadence field or a small &lt;code&gt;Schedule&lt;/code&gt; 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, &lt;em&gt;I&lt;/em&gt;) actually reach for templates. Same discipline as last post's exit ramp: design it, write it down, don't build it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model &lt;em&gt;kind&lt;/em&gt; orthogonally to &lt;em&gt;lifecycle&lt;/em&gt;.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Additive schema fields are the cheap kind.&lt;/strong&gt; Know which changes replace your DynamoDB table before you make them, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A flag is never just a flag.&lt;/strong&gt; Budget for every place that enumerates the model: the switcher, the bootstrap guard, the aggregation — each needed to learn what &lt;code&gt;isTemplate&lt;/code&gt; means.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship manual before scheduled.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;This feature closes out the "big pieces" of the shopping tab — which means it's time for the &lt;strong&gt;polish montage&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>aws</category>
      <category>architecture</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>You can't GROUP BY in DynamoDB — so the client does the counting</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Tue, 21 Jul 2026 22:38:09 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/you-cant-group-by-in-dynamodb-so-the-client-does-the-counting-4dnd</link>
      <guid>https://dev.to/hokagedemehin/you-cant-group-by-in-dynamodb-so-the-client-does-the-counting-4dnd</guid>
      <description>&lt;p&gt;The feature request was almost embarrassingly simple: groceries are mostly repeat purchases, so show one-tap chips of the user's usual items — &lt;em&gt;"Add again: Milk · Bread · Eggs."&lt;/em&gt; In SQL, that's a beginner exercise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shopping_items&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;me&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My database is DynamoDB, behind AppSync. &lt;strong&gt;There is no &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/strong&gt; No &lt;code&gt;COUNT&lt;/code&gt;, no aggregates, no &lt;code&gt;HAVING&lt;/code&gt; — the query model fetches items by key, full stop. So a one-line feature became an actual architecture decision, and the decision is more interesting than the feature.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — NoSQL doesn't do read-time aggregation, so you either &lt;strong&gt;materialise at write-time&lt;/strong&gt; (a counter table you upsert on every add) or &lt;strong&gt;aggregate at read-time on the client&lt;/strong&gt; (fetch history, count in JS). For a personal-scale dataset, ship the client scan — but give it explicit budgets (page size, page cap, cache TTL), make the counts honest (soft-deleted rows &lt;em&gt;in&lt;/em&gt;, template lists &lt;em&gt;out&lt;/em&gt;), and write down the exit ramp to the materialised model before you need it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 10 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork: count on write, or count on read
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option B — the "proper" one: a materialised &lt;code&gt;FrequentItem&lt;/code&gt; model.&lt;/strong&gt; A row per &lt;code&gt;(userId, normalised name)&lt;/code&gt; holding &lt;code&gt;count&lt;/code&gt; and &lt;code&gt;lastUsedAt&lt;/code&gt;, upserted on every add. Reading the top 8 is one cheap query. The costs: a new table (sandbox deploy), upsert discipline in every add path, and — to make the counts authoritative rather than best-effort — eventually a DynamoDB &lt;strong&gt;Streams&lt;/strong&gt; Lambda doing the increments server-side. That's real machinery for what is, today, a strip of eight chips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — the honest MVP: derive it from history, client-side.&lt;/strong&gt; Every item the user ever added is already sitting in the &lt;code&gt;ShoppingItem&lt;/code&gt; table. Fetch the user's rows, count by name in JS, rank, done. Zero backend changes, ships the same afternoon. The cost is proportional to history: you re-read everything to re-rank, which is fine for hundreds of rows and indefensible for millions.&lt;/p&gt;

&lt;p&gt;I have one user (me) and a few weeks of shopping history. &lt;strong&gt;A shipped.&lt;/strong&gt; But "A, carelessly" and "A, with budgets" are different features — the rest of this post is the budgets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scan, with limits it can't blow through
&lt;/h2&gt;

&lt;p&gt;The naive version of option A fetches &lt;em&gt;unboundedly&lt;/em&gt; — history only grows. So the scan gets explicit caps: page size, a page ceiling, and a cache TTL so it doesn't run on every render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// "frequent" = added on more than one occasion&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POOL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// ranked-pool cap; the screen slices to its display limit&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_PAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// pagination guard (~2,000 rows at limit 200)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useFrequentItems&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;frequentItems&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// re-rank at most every 5 minutes&lt;/span&gt;
    &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FrequentItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;nextToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextToken&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_PAGES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// … aggregate below&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If history ever exceeds the ~2,000-row budget, the ranking quietly degrades to "your most frequent among recent history" instead of falling over — and that's also the tripwire that says it's time for option B.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aggregation: a Map and two tie-breakers
&lt;/h2&gt;

&lt;p&gt;Counting is a &lt;code&gt;Map&lt;/code&gt; keyed on a normalised name — trimmed, lowercased, whitespace-collapsed — so "Oat  milk" and "oat milk" are one item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;frequentKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each occurrence bumps the count, and the &lt;strong&gt;freshest occurrence wins the display details&lt;/strong&gt; — casing, unit, category, quantity — so a chip doesn't just add "Milk", it adds &lt;em&gt;your usual milk&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Freshest occurrence wins the display casing + pre-fill defaults.&lt;/span&gt;
  &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then rank by count with a recency tie-break, drop one-offs, and keep a pool the screen can slice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MIN_COUNT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastAt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;POOL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Making the counts honest
&lt;/h2&gt;

&lt;p&gt;Two filtering decisions mattered more than the counting itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Soft-deleted and checked-off rows are &lt;em&gt;included&lt;/em&gt;.&lt;/strong&gt; This app never hard-deletes user data — a business "delete" flips &lt;code&gt;_deleted&lt;/code&gt; flags (the convention every model in this backend carries). That rule was made for auditability, but here it pays a dividend nobody planned: &lt;strong&gt;a checked-off, cleared-away item is the strongest possible repeat-purchase signal.&lt;/strong&gt; You bought it, it left the list — that's precisely the history worth mining. If deletes had been real deletes, this feature would have no data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template lists are &lt;em&gt;excluded&lt;/em&gt;.&lt;/strong&gt; The app also has reusable template lists ("Weekly essentials" — next post). A template's items are a &lt;em&gt;saved set&lt;/em&gt;, not a &lt;em&gt;shopping occasion&lt;/em&gt;; if they counted, instantiating a template would double-count everything in it forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Template lists are saved sets, not shopping occasions — drop their items&lt;/span&gt;
&lt;span class="c1"&gt;// so they don't inflate the frequency counts.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;templateIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userLists&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isTemplate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;templateIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// … count it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aggregation is easy; deciding &lt;em&gt;what deserves to be aggregated&lt;/em&gt; is the actual design work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chips themselves
&lt;/h2&gt;

&lt;p&gt;The screen subtracts anything already on the active list — a chip for something you've already added is noise — using the same normalised key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frequentChips&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;frequentKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;frequentPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;onList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_CHIPS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 8&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;frequentPool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They render in two places: a horizontal strip riding just above the capture bar, and a wrapped cluster in the empty state — a brand-new list greeting you with &lt;em&gt;your usual&lt;/em&gt; instead of a void. Tapping one goes through the same &lt;code&gt;addItems&lt;/code&gt; mutation as everything else, which means chip-adds inherit the &lt;a href="https://dev.to/hokagedemehin/working-offline-in-the-cereal-aisle-tanstack-query-offline-on-amplify-5cn0"&gt;offline queue from Part 7&lt;/a&gt; for free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmkwga2678804cr8qsa45.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmkwga2678804cr8qsa45.jpeg" alt="The frequently-bought strip riding above the capture bar: one-tap mint chips of your usual items" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq8fzrotfz3sudxjianii.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq8fzrotfz3sudxjianii.jpeg" alt="A brand-new list's empty state: instead of a void, a wrapped cluster of your usuals ready to add" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The exit ramp, written down in advance
&lt;/h2&gt;

&lt;p&gt;Option A's failure mode is known and gradual: history outgrows the scan budget, or the re-ranking read cost stops being funny. The upgrade is already designed — the materialised model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// NOT shipped — the documented option B&lt;/span&gt;
&lt;span class="nx"&gt;FrequentItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;nameKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  &lt;span class="c1"&gt;// frequentKey(name) — the dedup key&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;lastUsedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upsert client-side in the add path first (a rare double-count race is harmless for chips), and when the counts need to be authoritative, move the increment into a DynamoDB Streams Lambda on &lt;code&gt;ShoppingItem&lt;/code&gt; inserts. The important part isn't the design — it's that it's &lt;em&gt;written down next to the shipped code&lt;/em&gt;, so future-me switches when the tripwire fires instead of re-deriving the whole debate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NoSQL moves aggregation to write-time or read-time — there is no free &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/strong&gt; Materialise when data is big or shared; scan client-side when it's personal-scale. Both are legitimate; drifting between them accidentally is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A client scan without budgets is a time bomb.&lt;/strong&gt; Page caps, pool caps, and a cache TTL turn "unbounded read" into "bounded, degradable read."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your soft-deletes are a dataset.&lt;/strong&gt; Never hard-deleting user data was an auditability rule; it quietly made this feature possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the MVP &lt;em&gt;and&lt;/em&gt; the exit ramp.&lt;/strong&gt; The cheap version is only cheap if the upgrade path is already designed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;isTemplate&lt;/code&gt; flag doing quiet work in the exclusion filter is a whole feature: reusable template lists — "Weekly essentials," saved once, spun up fresh in one tap — built with &lt;strong&gt;zero new models&lt;/strong&gt;. Why a template is just a list with a flag: Post 11.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where do you do your aggregation on DynamoDB — Streams-materialised counters, client-side counting, or "we export to something with SQL and don't talk about it"? Genuinely curious where people draw the line.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dynamodb</category>
      <category>reactnative</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Whose categories are these? Making an LLM's throwaway labels feel like real things</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Tue, 21 Jul 2026 21:59:41 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/whose-categories-are-these-making-an-llms-throwaway-labels-feel-like-real-things-4bjg</link>
      <guid>https://dev.to/hokagedemehin/whose-categories-are-these-making-an-llms-throwaway-labels-feel-like-real-things-4bjg</guid>
      <description>&lt;p&gt;The request sounded trivial: &lt;em&gt;let me reorder the aisles so the list matches how I walk my store — and let me rename "Produce" to "Fruit &amp;amp; Veg" while you're at it.&lt;/em&gt; Reorder and rename. Settings-screen stuff.&lt;/p&gt;

&lt;p&gt;Small problem: &lt;strong&gt;the aisles don't exist.&lt;/strong&gt; When Claude parses "milk and a dozen eggs" it stamps &lt;code&gt;category: "Dairy"&lt;/code&gt; on an item and moves on. There's no Category table, no aisle entity, nothing to reorder — just strings, scattered across items, that happen to repeat. This post is about making things a user can &lt;em&gt;own&lt;/em&gt; out of labels an LLM &lt;em&gt;throws away&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Don't promote LLM-generated labels to a database model just to make them editable. Model each aisle client-side as &lt;code&gt;{ key, name, emoji }&lt;/code&gt;: the &lt;strong&gt;key&lt;/strong&gt; is the immutable string that lives on items (Claude's vocabulary), while &lt;strong&gt;name&lt;/strong&gt; and &lt;strong&gt;emoji&lt;/strong&gt; are editable display. Store the user's ordered list in one &lt;code&gt;a.json()&lt;/code&gt; field on their profile. Seed it with the standard aisles, and &lt;strong&gt;auto-add&lt;/strong&gt; any category the LLM invents so nothing is ever un-orderable. Rename becomes a display edit — no item rewrites, no migration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 9 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Context from &lt;a href="https://dev.to/hokagedemehin/milk-and-a-dozen-eggs-a-voice-to-claude-shopping-list-that-never-trusts-the-ai-mi4"&gt;Part 5&lt;/a&gt;: Claude turns speech into structured items, each with a free-text &lt;code&gt;category&lt;/code&gt;. Otherwise self-contained.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The three options, honestly weighed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option A — derive the aisles from the items.&lt;/strong&gt; No storage at all: the aisle list is just the distinct categories currently on the list. It's zero-schema and always accurate, but it's &lt;em&gt;unstable&lt;/em&gt; — check off your last Dairy item and the Dairy aisle vanishes, taking its position in your carefully arranged order with it. Add one tomorrow and it's back… at the bottom. You can't hang user preferences off something that flickers in and out of existence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B — hard-code the fixed set.&lt;/strong&gt; The parsing Lambda's prompt already names the standard grocery aisles (Produce, Bakery, Dairy, Meat, Frozen, Pantry, Drinks, Household), so just make that the editable list. Stable, simple — until Claude does what LLMs do. Say "a new laptop and some cat food" and your items land in &lt;code&gt;Electronics&lt;/code&gt; and &lt;code&gt;Pet&lt;/code&gt; — categories the fixed list has never heard of, now stuck unsortable at the bottom forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C — a first-class &lt;code&gt;ShoppingCategory&lt;/code&gt; model.&lt;/strong&gt; The "proper" answer: real rows, real ids, foreign keys from items. And for a per-user list of roughly &lt;em&gt;twelve entries&lt;/em&gt;, it's a sandbox deploy, CRUD plumbing, joins on every list render, and a data migration for existing items — to store what is functionally a user preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped: a seed, an auto-add, and one JSON field
&lt;/h2&gt;

&lt;p&gt;The decision: &lt;strong&gt;B's stability plus A's openness, in a preference.&lt;/strong&gt; Each aisle is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Aisle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fields, one rule that makes everything downstream free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;key&lt;/code&gt; never changes.&lt;/strong&gt; It's the category string exactly as it lives on items — Claude's canonical &lt;code&gt;"Dairy"&lt;/code&gt;. This is the aisle's &lt;em&gt;identity&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;name&lt;/code&gt; is the editable label.&lt;/strong&gt; Rename edits this and only this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;emoji&lt;/code&gt; is the section glyph.&lt;/strong&gt; Also editable, also display-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The user's ordered list lives in a single JSON column on their profile — the whole schema change is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// UserProfile&lt;/span&gt;
&lt;span class="nx"&gt;aisleOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// ordered [{key,name,emoji}] — per-user aisle display order&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeded from the same vocabulary the Lambda's prompt uses, so Claude's output maps straight onto an aisle: &lt;strong&gt;🥬 Produce · 🥖 Bakery · 🥛 Dairy · 🥩 Meat · 🧊 Frozen · 🥫 Pantry · 🥤 Drinks · 🧽 Household · 🛒 Other&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And here's why the &lt;code&gt;key&lt;/code&gt;/&lt;code&gt;name&lt;/code&gt; split earns its keep. When the user renames Produce to "Fruit &amp;amp; Veg", the items still say &lt;code&gt;category: "Produce"&lt;/code&gt;, Claude keeps emitting &lt;code&gt;"Produce"&lt;/code&gt;, and the section header renders the &lt;em&gt;name&lt;/em&gt;. &lt;strong&gt;Nothing else moves&lt;/strong&gt; — no item rewrite, no re-prompting the LLM, no migration. Identity and display were separated, so editing display costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why emoji, not icons
&lt;/h2&gt;

&lt;p&gt;The glyph choice looks cosmetic but was forced by a build-time constraint. This app's icon library (&lt;code&gt;react-native-iconify&lt;/code&gt; v1 — &lt;a href="https://dev.to/hokagedemehin/every-icon-vanished-a-metro-debugging-story-featuring-a-babel-package-from-2017-4ije"&gt;Part 3&lt;/a&gt; explains the version pin) works via a Babel plugin that scans JSX for &lt;strong&gt;static literal&lt;/strong&gt; icon names and inlines each SVG at build time. A per-aisle, &lt;em&gt;user-chosen&lt;/em&gt; icon is by definition a variable — the one thing that pipeline can't resolve.&lt;/p&gt;

&lt;p&gt;Emoji are just text. Fully dynamic, no registry, colourful by default, and they render identically in a section header and a settings row. An open-ended name→emoji lookup gives auto-added categories a sensible glyph, with a fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EMOJI_MAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;produce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🥬&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bakery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🥖&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dairy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🥛&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;meat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🥩&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;frozen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🧊&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;snacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🍿&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;coffee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;☕&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🐾&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;electronics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;💻&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// … ~40 entries&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;defaultEmojiFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;EMOJI_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🛒&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Auto-add: nothing is ever un-orderable
&lt;/h2&gt;

&lt;p&gt;The open-world half of the design. Whenever an item carries a category that isn't in the user's list yet, it gets appended — name defaulting to the key, emoji from the lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withUnlistedAisles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Aisle&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;seenKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;Aisle&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;have&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dedupe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seenKeys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;have&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defaultEmojiFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small effect on the list screen watches for this and saves the merged list. One guard mattered in practice — a session-scoped ref of already-attempted keys, so a failed save doesn't re-trigger the effect in a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Auto-add any category Claude produced that isn't in the aisle list yet, so&lt;/span&gt;
&lt;span class="c1"&gt;// it becomes reorderable/renamable instead of stuck last. Converges (the saved&lt;/span&gt;
&lt;span class="c1"&gt;// list then includes it); a session ref stops a failed save from re-looping.&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seenCategories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;known&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;attemptedAislesRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;attemptedAislesRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nf"&gt;addUnlistedAisles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withUnlistedAisles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aisles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seenCategories&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aisles&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So when Claude invents &lt;code&gt;Electronics&lt;/code&gt;, it appears at the end of the aisle editor — 💻, reorderable, renamable — instead of being a second-class string forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's holding up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The editor reuses &lt;a href="https://dev.to/hokagedemehin/i-deleted-the-drag-and-drop-library-and-shipped-four-buttons-instead-3ce1"&gt;Part 8&lt;/a&gt;'s tap-to-reorder menu&lt;/strong&gt; — Move to top/up/down/bottom, no drag library. The boring pattern got reused within a week of shipping, which is the best endorsement a pattern gets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saves are optimistic&lt;/strong&gt; (React Query &lt;code&gt;onMutate&lt;/code&gt;/rollback), so reordering feels instant even though it round-trips a profile update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWSJSON strikes again:&lt;/strong&gt; the stored JSON can come back single- or double-encoded depending on the layer, so the reader unwraps with the same &lt;code&gt;while (typeof v === "string") v = JSON.parse(v)&lt;/code&gt; loop from Part 5 — plus a shape check per entry, because this field is user-adjacent data, not a trusted API response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmra8ri041i4ae59b89y5.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmra8ri041i4ae59b89y5.jpeg" alt="The Aisle order editor in the More tab: every category as an emoji + name row, arranged in your walking order" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbzna23c4wh4u7fs4oeca.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbzna23c4wh4u7fs4oeca.jpeg" alt="Updating an aisle — rename it and pick its emoji; the items underneath keep the immutable key" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should promote to a real model
&lt;/h2&gt;

&lt;p&gt;The JSON-preference approach assumes categories are &lt;strong&gt;per-user, small, and display-only&lt;/strong&gt;. Promote to a first-class model when any of those breaks: categories shared across users or lists, per-category metadata that grows (budgets per aisle, store locations), or category-level soft-delete/audit needs. Those are entity problems and deserve entity treatment. A dozen display preferences aren't — and the single JSON field has the virtue that deleting the feature would be one column, not a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Those categories are about to earn their keep: the next feature mines the user's shopping history for the items they buy most — which runs straight into DynamoDB's most famous missing feature. &lt;strong&gt;You can't &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/strong&gt; What you do instead is Post 10.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where's your line for "this deserves a real table"? I keep finding that half my would-be models are actually preferences wearing a trench coat.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ai</category>
      <category>database</category>
    </item>
    <item>
      <title>I deleted the drag-and-drop library and shipped four buttons instead</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Tue, 21 Jul 2026 20:49:36 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/i-deleted-the-drag-and-drop-library-and-shipped-four-buttons-instead-3ce1</link>
      <guid>https://dev.to/hokagedemehin/i-deleted-the-drag-and-drop-library-and-shipped-four-buttons-instead-3ce1</guid>
      <description>&lt;p&gt;My shopping app shipped item reordering last week, and it contains zero drag gestures. You tap a ⇅ button on a row and pick from four menu options: &lt;em&gt;Move to top · Move up · Move down · Move to bottom&lt;/em&gt;. That's it. This is the story of the much fancier version I built the same afternoon — and deleted before dinner.&lt;/p&gt;

&lt;p&gt;The library never even made it into a commit. That's the tell.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Drag-to-reorder on a grouped &lt;code&gt;SectionList&lt;/code&gt; (inside a gesture-handler root, with bottom sheets, pull-to-refresh, and swipe actions already competing for touches) triggered a cascade of Android issues: a crash, a frozen refresh, overlapping headers, janky scroll. Each fix surfaced the next — the signature of an integration fight, not a bug. The requirement said &lt;em&gt;reorder&lt;/em&gt;, not &lt;em&gt;drag&lt;/em&gt; — so I deleted the library and shipped a plain SectionList + a bottom-sheet menu that renumbers &lt;code&gt;position&lt;/code&gt; optimistically. Boring won.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 8 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: a list that was already gesture-crowded
&lt;/h2&gt;

&lt;p&gt;The Shopping tab is a grouped checklist — items under aisle headers (Produce, Dairy, …) in a React Native &lt;code&gt;SectionList&lt;/code&gt;. By the time reordering came up, that screen was already juggling touch handlers from every direction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the app-wide &lt;code&gt;GestureHandlerRootView&lt;/code&gt; (load-bearing, outermost in the provider stack),&lt;/li&gt;
&lt;li&gt;gorhom &lt;strong&gt;bottom sheets&lt;/strong&gt; with pan-to-dismiss,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pull-to-refresh&lt;/strong&gt; on the list,&lt;/li&gt;
&lt;li&gt;checkbox taps, row taps (edit sheet), row &lt;strong&gt;swipe-to-delete&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Users should reorder items" was on the list, and drag-and-drop is the obviously right interaction for that — every design instinct says so. So I reached for a drag library, &lt;code&gt;react-native-reorderable-list&lt;/code&gt;, and wired it in place of the plain list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cascade
&lt;/h2&gt;

&lt;p&gt;What followed, in order (from my build notes of that session — this all happened inside one afternoon):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A crash.&lt;/strong&gt; The reorderable list didn't want to live inside the existing gesture/layout arrangement.&lt;/li&gt;
&lt;li&gt;Restructure, retry — the crash cleared, and &lt;strong&gt;pull-to-refresh froze&lt;/strong&gt;. The refresh spinner and the drag recognizer were now fighting over the same vertical pan.&lt;/li&gt;
&lt;li&gt;Work around that — &lt;strong&gt;section headers started overlapping rows&lt;/strong&gt; after a drag; the library's layout animation and the sticky-header math disagreed about where things were.&lt;/li&gt;
&lt;li&gt;And underneath it all, &lt;strong&gt;scroll got janky&lt;/strong&gt; — the one thing a list must never be.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each fix surfaced the next problem, one layer down. That pattern is worth naming, because it's the diagnostic: when fixes &lt;em&gt;reveal&lt;/em&gt; rather than &lt;em&gt;resolve&lt;/em&gt;, you're not debugging a bug — you're fighting an integration. The library was fine. My screen was fine. They just couldn't share a gesture system on Android without me re-architecting one of them.&lt;/p&gt;

&lt;p&gt;Every one of these, on its own, felt fixable. I know how these bugs go — another hour, another workaround. That's precisely the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision point
&lt;/h2&gt;

&lt;p&gt;Somewhere around the header overlap, I stopped and re-read the actual requirement. It wasn't "users can drag items." It was &lt;strong&gt;"users can reorder items."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drag is one implementation of reorder — the prettiest one, and on this particular screen, the most expensive one. A menu is another implementation: tap ⇅ on the row, pick &lt;em&gt;Move up&lt;/em&gt; / &lt;em&gt;Move to top&lt;/em&gt; / etc. It has &lt;strong&gt;zero gesture surface&lt;/strong&gt; — a tap can't fight a pan recognizer, can't freeze a refresh spinner, can't confuse a sticky header. And a menu is honestly &lt;em&gt;more&lt;/em&gt; usable in context: a grocery list is operated one-handed, walking, pushing a trolley. Long-press-and-drag with your thumb while the phone wobbles is a worse interaction than two taps, and it's kinder to anyone without fine motor control.&lt;/p&gt;

&lt;p&gt;So: &lt;code&gt;npm uninstall&lt;/code&gt;. The library left no trace — not in a commit, not in the lockfile. The whole arc, from install to delete, fits inside one working session. The best review the drag version ever got is that it never survived to a commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped: four buttons and some renumbering
&lt;/h2&gt;

&lt;p&gt;The row's ⇅ button opens a bottom sheet. The options self-limit — an item at the top of its aisle doesn't offer "Move up":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reorder-sheet.tsx (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;MoveAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bottom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;canUp&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MoveRow&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Move to top"&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;canUp&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MoveRow&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Move up"&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;canDown&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MoveRow&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Move down"&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;canDown&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MoveRow&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Move to bottom"&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bottom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The move itself is array surgery plus a renumber. Items live under aisle sections but &lt;code&gt;position&lt;/code&gt; is global across the list, so after moving within the category I re-derive positions for everything and only write the rows whose number actually changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shopping/index.tsx (excerpt)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyReorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;catKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;catItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;moved&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;catItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;catItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;moved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Rebuild the global order, then diff: only changed positions get written.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Optimistic: patch + resort the cache, then persist.&lt;/span&gt;
  &lt;span class="nx"&gt;qc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setQueryData&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shoppingItems&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="cm"&gt;/* patch + sort by position */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;reorderItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cache patch means the row hops instantly; the mutation persists in the background (and, since &lt;a href="https://dev.to/hokagedemehin/working-offline-in-the-cereal-aisle-tanstack-query-offline-on-amplify-5cn0"&gt;Part 7&lt;/a&gt;, even offline). Total surface area: one sheet component, one function, no new dependencies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fym8tbuhdkbrtn5endau2.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fym8tbuhdkbrtn5endau2.jpeg" alt="The shopping list: each row carries a ⇅ reorder button on the right — a tap target, not a gesture" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu0mn7j4ncimeh4de5u2s.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu0mn7j4ncimeh4de5u2s.jpeg" alt="The reorder bottom sheet: the item's name, " width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The villain found in the wreckage
&lt;/h2&gt;

&lt;p&gt;The debugging detour did pay one real dividend. While untangling gesture handlers I found this, wrapped around the &lt;em&gt;entire screen&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- &amp;lt;TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}&amp;gt;
&lt;/span&gt;    &amp;lt;View className="flex-1"&amp;gt;
      {/* …the whole shopping screen… */}
    &amp;lt;/View&amp;gt;
&lt;span class="gd"&gt;- &amp;lt;/TouchableWithoutFeedback&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was there for a reasonable purpose — tap anywhere to dismiss the keyboard. But on Android it was &lt;strong&gt;eating the first touch of every scroll&lt;/strong&gt;: touch the list while the wrapper is watching, and your gesture goes to the touchable first, so the scroll doesn't start until the &lt;em&gt;second&lt;/em&gt; attempt. That subtle "the list feels slightly dead" wrongness had been there the whole time — the drag investigation is what finally surfaced it. It's gone now (keyboard dismissal moved to the specific interactions that need it), and scroll response was noticeably better even before any reorder feature existed.&lt;/p&gt;

&lt;p&gt;Wrong theories sometimes pay rent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-read the requirement before debugging the solution.&lt;/strong&gt; "Reorder" ≠ "drag." I was an hour into fixing an implementation nobody had actually asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When fixes reveal instead of resolve, stop.&lt;/strong&gt; A bug yields to a fix. An integration fight yields a &lt;em&gt;new&lt;/em&gt; bug per fix. Different problem, different move — change the approach, not the patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Android, every gesture library negotiates with every other one.&lt;/strong&gt; Root view, sheets, refresh, swipes, drag — each is reasonable alone; the combination is the risk. Budget for the &lt;em&gt;interactions&lt;/em&gt;, not the features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deleted dependency is the cheapest dependency.&lt;/strong&gt; Zero commits, zero lockfile entries, zero future upgrades that break. The boring version has needed no attention since.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;The aisle headers those items sort under have their own story: the categories are &lt;strong&gt;free-text labels an LLM stamps on each item&lt;/strong&gt; — so how do you let a user reorder, rename, and emoji things that aren't really entities? That's Post 9.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the library you deleted and immediately felt relief? I'm collecting evidence that "rip it out" is an underrated debugging technique.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>android</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Working offline in the cereal aisle: TanStack Query offline on Amplify</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Tue, 21 Jul 2026 20:09:55 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/working-offline-in-the-cereal-aisle-tanstack-query-offline-on-amplify-5cn0</link>
      <guid>https://dev.to/hokagedemehin/working-offline-in-the-cereal-aisle-tanstack-query-offline-on-amplify-5cn0</guid>
      <description>&lt;p&gt;The one place a shopping app absolutely has to work is the one place phones give up: the middle of a supermarket, three aisles deep, no signal. So this had to be true — check off milk, add oat milk, fix a quantity, all offline, and have every change land silently once you're back on WiFi at the checkout. No spinner of death, no lost taps.&lt;/p&gt;

&lt;p&gt;The surprise: I didn't need a sync engine to do it. React Query already pauses, persists, and replays mutations. Two small tricks make it survive a real trip to the store.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — For offline-first on Amplify, don't reach for DataStore — stay in TanStack Query. Persist the query cache to AsyncStorage; wire &lt;code&gt;onlineManager&lt;/code&gt; to NetInfo so React Query pauses mutations offline; make those mutations &lt;strong&gt;optimistic&lt;/strong&gt; and register them as keyed &lt;strong&gt;mutation defaults&lt;/strong&gt; so a paused mutation can replay via &lt;code&gt;resumePausedMutations&lt;/code&gt; &lt;em&gt;even after the app is killed&lt;/em&gt;; and give every create a &lt;strong&gt;client-generated UUID&lt;/strong&gt; so an offline row keeps its identity on sync. No schema change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 7 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork: DataStore, or stay in React Query
&lt;/h2&gt;

&lt;p&gt;Amplify ships &lt;strong&gt;DataStore&lt;/strong&gt; — a genuine offline sync engine with a local store and conflict resolution. It's the "official" answer, and for a collaborative, multi-user, heavily-relational app it's the right one.&lt;/p&gt;

&lt;p&gt;But adopting it here meant ripping out the data layer the whole app already stands on: the typed Amplify client, &lt;code&gt;observeQuery&lt;/code&gt;, and TanStack Query for server state. That's a big architectural U-turn to serve &lt;em&gt;one&lt;/em&gt; feature — and it trades a stack I understand for a sync engine I'd have to learn the failure modes of.&lt;/p&gt;

&lt;p&gt;The other option: &lt;strong&gt;TanStack Query already has first-class offline support.&lt;/strong&gt; The app is single-user (your own grocery list), so last-write-wins is a perfectly fine conflict policy, and I get to keep everything I've already built. I stayed. The rest of this post is what "staying" actually takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;persist the cache   → AsyncStorage, so last-known lists/items read with no network
onlineManager ← NetInfo → React Query knows it's offline, pauses mutations
optimistic mutations as defaults → UI reacts instantly; paused ones replay after an app kill
client-generated IDs → an offline create keeps its id once it syncs
sync banner → the one visible bit: "offline" / "syncing N changes"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No schema change.&lt;/strong&gt; The models already have &lt;code&gt;createdAt&lt;/code&gt;/&lt;code&gt;updatedAt&lt;/code&gt;; conflict policy is last-write-wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 1 — persist the cache
&lt;/h2&gt;

&lt;p&gt;Offline reads come from the query cache, so the cache has to outlive the process. &lt;code&gt;PersistQueryClientProvider&lt;/code&gt; + an AsyncStorage persister does that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/query-persist.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BUSTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cannycart-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// bump to discard old cache when the shape changes&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;asyncPersister&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAsyncStoragePersister&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cannycart:rq-cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;throttleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;persistOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;persister&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;asyncPersister&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;buster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BUSTER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 7 days&lt;/span&gt;
  &lt;span class="na"&gt;dehydrateOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Only persist paused offline mutations we can actually resume — the ones&lt;/span&gt;
    &lt;span class="c1"&gt;// we registered defaults for (mutationKey ["shopping", …]). Anything else&lt;/span&gt;
    &lt;span class="c1"&gt;// paused offline is dropped rather than restored un-resumable.&lt;/span&gt;
    &lt;span class="na"&gt;shouldDehydrateMutation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPaused&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mutationKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mutationKey&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shopping&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;shouldDehydrateMutation&lt;/code&gt; filter matters more than it looks — persisting a paused mutation you &lt;em&gt;can't&lt;/em&gt; rebuild on the next launch is worse than dropping it, because it resurrects as a zombie with no function to run. Only persist what you can resume. (Which is Piece 3.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 2 — know when you're offline
&lt;/h2&gt;

&lt;p&gt;React Query doesn't detect connectivity itself — you tell it, by pointing &lt;code&gt;onlineManager&lt;/code&gt; at NetInfo. One side-effect module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/react-query-native.ts — wires onlineManager to NetInfo&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;NetInfo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@react-native-community/netinfo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onlineManager&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@tanstack/react-query&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;onlineManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEventListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;setOnline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;NetInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setOnline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isConnected&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, when a mutation fires offline, React Query &lt;strong&gt;pauses&lt;/strong&gt; it instead of failing it — and that pause is the hook everything else hangs off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 3 — the trick that survives an app kill
&lt;/h2&gt;

&lt;p&gt;Here's the part that took the most thought. A paused mutation is easy to resume &lt;em&gt;within a session&lt;/em&gt;. But a shopper puts the phone in their pocket; iOS kills the app; they reopen it in the car. The paused mutation was persisted to disk — but a function can't be serialized. So on relaunch, how does React Query know &lt;em&gt;what to run&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;The answer is &lt;strong&gt;mutation defaults keyed by &lt;code&gt;mutationKey&lt;/code&gt;&lt;/strong&gt;. You register each mutation's function at module load, under a stable key. On relaunch, React Query rehydrates the paused mutation, looks up the default by its key, and rebuilds the function to replay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/shopping-mutations.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;addItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shopping&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;addItems&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;toggleItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shopping&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;toggleItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// …updateItem, softDeleteItem, clearChecked&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMutationDefaults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toggleItem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checked&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ToggleItemVars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checked&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onMutate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checked&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ToggleItemVars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;optimistic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
  &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;onSettled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToggleItemVars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;onMutate&lt;/code&gt;/&lt;code&gt;onError&lt;/code&gt;/&lt;code&gt;onSettled&lt;/code&gt; trio is the optimistic loop: snapshot the cache and apply the change immediately, roll back if it errors, and reconcile with the server once it settles. The snapshot helper is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;optimistic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;itemsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getQueryData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;itemsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setQueryData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;itemsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// for rollback&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the function lives in a keyed default (not inline at the call site), it exists again the instant the module loads on the next launch — ready for the persisted mutation to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 4 — client-generated IDs
&lt;/h2&gt;

&lt;p&gt;Optimistic-add has a classic bug: you show a row offline with some temporary id, the server later assigns the &lt;em&gt;real&lt;/em&gt; id, and now your offline edits/toggles point at a ghost. Amplify lets you supply the &lt;code&gt;id&lt;/code&gt; on create, so I stamp a UUID in &lt;code&gt;onMutate&lt;/code&gt; — into the variables themselves, so the same id flows through the optimistic row, the eventual server row, and the persisted paused mutation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;onMutate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AddItemsVars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uuidv4&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// stamp stable ids&lt;/span&gt;
  &lt;span class="c1"&gt;// …build optimistic rows using it.id…&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;AddItemsVars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShoppingItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One identity from creation to sync. An item added in aisle 5 and checked off in aisle 7 is the same row when it all lands — no duplicate, no orphaned edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 5 — a calm sync banner
&lt;/h2&gt;

&lt;p&gt;The only visible surface is a thin status bar, and it's deliberately &lt;strong&gt;teal, not red&lt;/strong&gt; — being offline isn't an error, it's a normal state of a grocery trip. It reads connectivity and how many &lt;code&gt;["shopping"]&lt;/code&gt; mutations are still in flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useSyncStatus&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;online&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOnline&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;onlineManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOnline&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;onlineManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setOnline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onlineManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOnline&lt;/span&gt;&lt;span class="p"&gt;())),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useIsMutating&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mutationKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shopping&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;online&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Offline → "changes save here and sync when you're back." Online with a queue → "Syncing N changes…". Online and empty → the banner returns &lt;code&gt;null&lt;/code&gt; and vanishes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpvx3o7ycgxs3p3vykbsg.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpvx3o7ycgxs3p3vykbsg.jpeg" alt="The shopping list offline: the teal banner reads " width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvgwwyh1t9mi607krkgeu.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvgwwyh1t9mi607krkgeu.jpeg" alt="Back online: the banner switches to " width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The wiring order that bites
&lt;/h2&gt;

&lt;p&gt;There's one ordering trap. &lt;code&gt;resumePausedMutations&lt;/code&gt; can only rebuild a persisted mutation if its default is &lt;em&gt;already registered&lt;/em&gt;. So the module that registers the defaults must be imported &lt;strong&gt;before&lt;/strong&gt; the provider tries to resume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/shopping-mutations&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← registers defaults FIRST (side-effect import)&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PersistQueryClientProvider&lt;/span&gt;
  &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;persistOptions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;persistOptions&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Cache restored — now replay anything queued while offline.&lt;/span&gt;
    &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resumePausedMutations&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resume before the defaults exist and the paused mutations rehydrate with no function to run — they just sit there. The fix is literally import order.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's holding up (and the caveats)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Last-write-wins.&lt;/strong&gt; Fine for a single-user list. The day list &lt;strong&gt;sharing&lt;/strong&gt; lands, that's a different post — two people editing one list needs real conflict resolution, and &lt;em&gt;that's&lt;/em&gt; the case where DataStore earns its weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice stays online-only.&lt;/strong&gt; &lt;code&gt;parseShoppingList&lt;/code&gt; needs the network and Claude, so the mic politely no-ops offline. Offline covers manual add / check / edit / delete — which is the whole in-store loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay ordering.&lt;/strong&gt; A create has to land before the edits made against it. Client-generated ids plus in-order queueing handle this: the same id is valid the moment the row is optimistically created, so a follow-up toggle is never "ahead" of its target.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When you should choose DataStore instead
&lt;/h2&gt;

&lt;p&gt;Stay in React Query when you're single-user, last-write-wins is acceptable, and you already own your data layer. Reach for &lt;strong&gt;DataStore&lt;/strong&gt; (or a purpose-built sync engine) when you have &lt;strong&gt;multi-user shared documents&lt;/strong&gt;, need &lt;strong&gt;real conflict resolution&lt;/strong&gt;, or have deeply relational data that has to stay consistent offline. Offline-first is a spectrum; this is the light, boring end of it — which is exactly what a personal shopping list wants.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the offline story in your app — full sync engine, or the React-Query-pause-and-replay approach? And if you've shipped shared/collaborative offline, I'd love to read how you handled the conflicts I get to ignore.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>reactquery</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
