<?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 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>
    <item>
      <title>Light, dark, and system themes in Expo + NativeWind — the whole app, from one setting</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Mon, 20 Jul 2026 17:50:53 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/light-dark-and-system-themes-in-expo-nativewind-the-whole-app-from-one-setting-566i</link>
      <guid>https://dev.to/hokagedemehin/light-dark-and-system-themes-in-expo-nativewind-the-whole-app-from-one-setting-566i</guid>
      <description>&lt;p&gt;Most "dark mode in React Native" tutorials stop at &lt;code&gt;dark:&lt;/code&gt; classes and call it done. But a real setting needs three states — &lt;strong&gt;Light&lt;/strong&gt;, &lt;strong&gt;Dark&lt;/strong&gt;, and &lt;strong&gt;System&lt;/strong&gt; — it has to survive an app restart, and the native chrome (tab bar, navigation header) has to follow along, not just your own views. Here's the full thing, and the nice surprise is how little wiring the last part takes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Define your colors once as a palette with light + dark stops in &lt;code&gt;tailwind.config&lt;/code&gt;, so every class is a pair (&lt;code&gt;bg-page dark:bg-page-dark&lt;/code&gt;). Then a single call — NativeWind's &lt;code&gt;colorScheme.set(pref)&lt;/code&gt; — applies the user's choice. It drives &lt;em&gt;both&lt;/em&gt; the &lt;code&gt;dark:&lt;/code&gt; variants &lt;em&gt;and&lt;/em&gt; React Native's &lt;code&gt;Appearance&lt;/code&gt;, so expo-router's theme and the native tab bar switch for free. Hold the choice in Redux, persist it to AsyncStorage, and restore it on launch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 6 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. This one's a self-contained Expo how-to — no earlier context needed.)&lt;/em&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%2Fmfrn2bvc995wz36o2x71.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%2Fmfrn2bvc995wz36o2x71.jpeg" alt="The Shopping list in light mode — cards, mint category chips, ink text, and the tab bar" 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%2Fb4r81nwq5vllm1f4t815.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%2Fb4r81nwq5vllm1f4t815.jpeg" alt="The same Shopping list in dark mode — surfaces, the brand teal, and the tab bar all swapped" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;You're on &lt;strong&gt;Expo + NativeWind&lt;/strong&gt; and you already sprinkle &lt;code&gt;dark:&lt;/code&gt; classes around. You want to turn that into an actual &lt;strong&gt;Appearance&lt;/strong&gt; setting a user can pick — including a "System" option that follows the phone — and you want it to be remembered and to theme the whole app, tab bar included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expo SDK 57&lt;/strong&gt;, expo-router&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NativeWind v3&lt;/strong&gt; (Tailwind v3 under the hood)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redux Toolkit&lt;/strong&gt; for the in-app choice&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@react-native-async-storage/async-storage&lt;/code&gt;&lt;/strong&gt; for persistence&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shape, before any steps
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;palette (one object)  → tailwind.config dark stops → bg-page dark:bg-page-dark
user picks a mode     → Redux prefsSlice.theme      (in-app source of truth)
                      → colorScheme.set(pref)        (drives NativeWind + RN Appearance)
                      → AsyncStorage                 (persist across launches)
on launch             → load pref → dispatch + apply (restore, no flash)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five moving parts, and exactly one of them (&lt;code&gt;colorScheme.set&lt;/code&gt;) is doing the heavy lifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — one palette, dark stops in Tailwind
&lt;/h2&gt;

&lt;p&gt;Define colors once and give each a dark counterpart in &lt;code&gt;tailwind.config&lt;/code&gt;, so a token becomes a pair. This keeps "what colour is a card" in a single place instead of scattered across components.&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;// tailwind.config.ts&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;palette&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;./src/constants/design-tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;content&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;./src/**/*.{js,jsx,ts,tsx}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;presets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nativewind/preset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;neutral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;neutral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;surface&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;neutral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textPrimary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inverse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textPrimary&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bright&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="cm"&gt;/* teal on dark */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="c1"&gt;// …line, coral, semantic trio, each with its dark stop&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;satisfies&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every surface is a class pair. You write the light token and its dark stop together, and the &lt;code&gt;dark:&lt;/code&gt; variant does the switch:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex-1 bg-page p-5 dark:bg-page-dark"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-ink dark:text-ink-inverse"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Weekly shop&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;One RN gotcha worth stealing: React Native can't synthesize font weights, so each weight is a separate loaded font file and its own family (&lt;code&gt;font-nunito&lt;/code&gt;, &lt;code&gt;font-nunito-bold&lt;/code&gt;, …) — not &lt;code&gt;font-bold&lt;/code&gt;. Unrelated to theming, but it lives in the same config and bites everyone once.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 2 — the one call that themes everything
&lt;/h2&gt;

&lt;p&gt;Here's the whole engine. NativeWind exposes an imperative &lt;code&gt;colorScheme&lt;/code&gt; handle; setting it to &lt;code&gt;"light"&lt;/code&gt;, &lt;code&gt;"dark"&lt;/code&gt;, or &lt;code&gt;"system"&lt;/code&gt; is the entire apply step:&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/theme-mode.ts&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;colorScheme&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;nativewind&lt;/span&gt;&lt;span class="dl"&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;function&lt;/span&gt; &lt;span class="nf"&gt;applyThemePref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ThemePref&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;colorScheme&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;pref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "light" | "dark" | "system"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason this is the whole engine — and not just half of it — is what &lt;code&gt;colorScheme.set&lt;/code&gt; touches. It drives NativeWind's &lt;code&gt;dark:&lt;/code&gt; variants &lt;strong&gt;and&lt;/strong&gt; React Native's &lt;code&gt;Appearance&lt;/code&gt; API. So anything reading &lt;code&gt;useColorScheme()&lt;/code&gt; updates too: expo-router's &lt;code&gt;ThemeProvider&lt;/code&gt;, and with it your navigation header and the native tab bar. You theme your own views with &lt;code&gt;dark:&lt;/code&gt; classes; the framework chrome comes along for free because it's reading the same signal.&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 — nav + tab bar follow useColorScheme(), which colorScheme.set() feeds&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colorScheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useColorScheme&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ThemeProvider&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;colorScheme&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;DarkTheme&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DefaultTheme&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AuthGate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppTabs&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;AuthGate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ThemeProvider&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;That's the payoff of the whole post: &lt;strong&gt;you don't theme each surface separately.&lt;/strong&gt; One call, and the utility classes, the nav theme, and the tab bar all move together. Here's a second, entirely different screen — the More tab — proving it: same setting, and the list rows, header, and tab bar all follow.&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%2Ff7hepw6q74jlhg5dx3ml.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%2Ff7hepw6q74jlhg5dx3ml.jpeg" alt="The More tab in light mode — the settings rows where the Appearance setting lives" 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%2Ftw4wurnps4hnd35imyot.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%2Ftw4wurnps4hnd35imyot.jpeg" alt="The More tab in dark mode — list rows, header, and tab bar all following the same setting" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — a source of truth in Redux
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;colorScheme.set()&lt;/code&gt; applies a mode, but your UI still needs to &lt;em&gt;know&lt;/em&gt; the current choice — to check the selected row in the picker, for instance. That's client state, so it goes in Redux (server data belongs to React Query — never mix the two):&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;// store/slices/prefsSlice.ts&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;ThemePref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&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;light&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;dark&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;initialState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ThemePref&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;prefsSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSlice&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prefs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reducers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;setThemePref&lt;/span&gt;&lt;span class="p"&gt;:&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="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PayloadAction&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ThemePref&lt;/span&gt;&lt;span class="o"&gt;&amp;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="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&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;h2&gt;
  
  
  Step 4 — persist it to AsyncStorage
&lt;/h2&gt;

&lt;p&gt;A setting that forgets itself on relaunch isn't a setting. Load and save the pref under one key, and keep both operations non-fatal — a storage hiccup should never crash the app or block the theme from applying for the current session:&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/theme-mode.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;THEME_KEY&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:themePref&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&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;loadThemePref&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;ThemePref&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;try&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;stored&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;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;THEME_KEY&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;stored&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&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;stored&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="c1"&gt;// fall through to default&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// sensible default: follow the phone&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&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;saveThemePref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ThemePref&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="k"&gt;void&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;try&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;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;THEME_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pref&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="c1"&gt;// non-fatal: the pref still applies for this session&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;
  
  
  Step 5 — the Appearance picker
&lt;/h2&gt;

&lt;p&gt;Now the UI. Three rows; selecting one does three things in order — record it in Redux, apply it, and persist it (fire-and-forget, because the apply shouldn't wait on disk):&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/more/appearance.tsx&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ThemePref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;setThemePref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pref&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 1. source of truth&lt;/span&gt;
  &lt;span class="nf"&gt;applyThemePref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// 2. colorScheme.set — themes the whole app&lt;/span&gt;
  &lt;span class="nf"&gt;saveThemePref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// 3. persist, fire-and-forget&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rows themselves are just class pairs, with the selected one tinted and checked:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`flex-row items-center gap-3 px-4 py-4
    &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-brand-mint dark:bg-brand-mint-dark&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="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-ink dark:text-ink-inverse"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;selected&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;Iconify&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mdi:check-circle"&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;brand&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="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6 — restore on launch, without a flash
&lt;/h2&gt;

&lt;p&gt;Persistence is only half of "remembered." On the next launch you have to read the pref back and apply it &lt;em&gt;before&lt;/em&gt; the app paints, or the user sees a flash of the wrong theme. A tiny component does it — it renders nothing, sits inside the Redux provider, and applies the stored pref on mount:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ThemeModeInit&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;dispatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAppDispatch&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;loadThemePref&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;pref&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;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;setThemePref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pref&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// sync Redux&lt;/span&gt;
      &lt;span class="nf"&gt;applyThemePref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// and the actual theme&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;dispatch&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// …mounted first thing inside &amp;lt;ReduxProvider&amp;gt;, above the rest of the tree:&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReduxProvider&lt;/span&gt; &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;store&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ThemeModeInit&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* QueryClient → SafeArea → ThemeProvider → tabs … */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ReduxProvider&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;The native splash is already held while fonts load (&lt;code&gt;SplashScreen.preventAutoHideAsync()&lt;/code&gt;), which gives the async read a beat to resolve before the first real frame — so System-in-dark comes up dark, not a white flash then dark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it works
&lt;/h2&gt;

&lt;p&gt;Four checks, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;System follows the phone.&lt;/strong&gt; Pick &lt;em&gt;System&lt;/em&gt;, then flip your device to Dark in Control Center / Quick Settings — the app should switch live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Light/Dark override.&lt;/strong&gt; Pick &lt;em&gt;Dark&lt;/em&gt; on a light phone (and vice versa) — it should hold regardless of the device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It persists.&lt;/strong&gt; Fully kill and relaunch — your last choice should come back with no flash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The chrome follows.&lt;/strong&gt; In dark mode, confirm the &lt;strong&gt;tab bar and the navigation header&lt;/strong&gt; are dark too — that's the &lt;code&gt;colorScheme.set&lt;/code&gt; → &lt;code&gt;Appearance&lt;/code&gt; link doing its job. If your own views go dark but the tab bar stays light, you themed with &lt;code&gt;dark:&lt;/code&gt; but never called &lt;code&gt;colorScheme.set&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;Two threads from here. First, &lt;strong&gt;per-user hygiene&lt;/strong&gt;: this key is global, so on a shared device one account's theme would greet the next. Clearing user-specific AsyncStorage keys on sign-in/out (the &lt;code&gt;user-prefs-cache-setup&lt;/code&gt; pattern) closes that. Second, the web app is still on light-only — the same palette already feeds its Tailwind config, so the port is mostly mode plumbing, not redesign.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Which surface in your app was the last to get the theming memo? For me it's always a hard-coded &lt;code&gt;#fff&lt;/code&gt; on a shadow or a border, smug and glowing in dark mode. Tell me yours.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>nativewind</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>"Milk, and a dozen eggs": a voice-to-Claude shopping list that never trusts the AI</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Mon, 20 Jul 2026 16:39:13 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/milk-and-a-dozen-eggs-a-voice-to-claude-shopping-list-that-never-trusts-the-ai-mi4</link>
      <guid>https://dev.to/hokagedemehin/milk-and-a-dozen-eggs-a-voice-to-claude-shopping-list-that-never-trusts-the-ai-mi4</guid>
      <description>&lt;p&gt;I hold the mic and say: &lt;em&gt;"milk, a couple of bananas, a dozen eggs, and some ripe avocados."&lt;/em&gt; Two seconds later a tidy list appears — &lt;strong&gt;Milk ×1&lt;/strong&gt;, &lt;strong&gt;Banana ×2&lt;/strong&gt;, &lt;strong&gt;Egg ×12&lt;/strong&gt;, &lt;strong&gt;Avocado&lt;/strong&gt; &lt;em&gt;(ripe)&lt;/em&gt; — each already filed under Dairy or Produce. But it doesn't save. It waits for me to say yes.&lt;/p&gt;

&lt;p&gt;The interesting part of this feature isn't the AI. It's the deliberate gap I put between the AI and my database.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — On-device speech-to-text turns speech into a transcript; a Claude Haiku Lambda turns the transcript into structured JSON items; and — the key decision — that mutation &lt;strong&gt;returns the items without saving them&lt;/strong&gt;. The client shows a review sheet, and rows are only written when the user taps &lt;em&gt;Add&lt;/em&gt;. An LLM in your pipeline should never write straight to your data store. Put a human tap between the model and persistence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 5 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. The last three posts were the backend and the build traps; this is the feature all of it existed for.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal, and the one constraint that shaped everything
&lt;/h2&gt;

&lt;p&gt;The goal is boring to state and hard to earn: &lt;strong&gt;say what you want to buy, get a clean editable list.&lt;/strong&gt; Quantities resolved ("a dozen" → 12), items title-cased and singular ("bananas" → Banana), each dropped into an aisle so the list sorts itself.&lt;/p&gt;

&lt;p&gt;The constraint is the whole story. An LLM is &lt;em&gt;very good&lt;/em&gt; at this and &lt;em&gt;occasionally&lt;/em&gt; wrong — it will mishear "eggs" as "legs," miscount, or confidently invent a category. That's fine for a suggestion and unacceptable for a data store. So the rule I built around: &lt;strong&gt;Claude never writes to the database.&lt;/strong&gt; It proposes; the user disposes. Everything below serves that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline in one breath
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mic
 └─ expo-speech-recognition (on-device)      → transcript string
     └─ parseShoppingList mutation (AppSync)  → parse-shopping-list Lambda
         └─ Claude Haiku                       → JSON [{name,quantity,unit,category,note}]
             └─ review sheet (client)          → user edits / removes / confirms
                 └─ addItems()                  → ShoppingItem rows created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five hops, and the LLM sits in the middle with no write access to anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: on-device speech, accumulating a transcript
&lt;/h2&gt;

&lt;p&gt;Speech-to-text is &lt;code&gt;expo-speech-recognition&lt;/code&gt; (iOS &lt;code&gt;SFSpeechRecognizer&lt;/code&gt; / Android &lt;code&gt;SpeechRecognizer&lt;/code&gt;), wrapped in a hook. The one subtlety: the recognizer streams two kinds of result — &lt;em&gt;interim&lt;/em&gt; guesses that change as you speak, and &lt;em&gt;final&lt;/em&gt; segments that don't. The UI needs both: finalized text stays put while the live guess trails after it, greyed out.&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="nf"&gt;useSpeechRecognitionEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;result&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;event&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&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;transcript&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isFinal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setTranscript&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="o"&gt;=&amp;gt;&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="s2"&gt;`&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="s2"&gt; &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="s2"&gt;`&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nf"&gt;setInterim&lt;/span&gt;&lt;span class="p"&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;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setInterim&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="c1"&gt;// live guess for the current utterance&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;Running with &lt;code&gt;continuous: true&lt;/code&gt; means it keeps listening across pauses, so you can think mid-sentence. On stop, &lt;code&gt;transcript + interim&lt;/code&gt; is the full text to parse.&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%2Fsnqbz3ehzighf1k4g1t5.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%2Fsnqbz3ehzighf1k4g1t5.jpeg" alt="The voice capture sheet recording — a pulsing teal mic, listening for the shopping list" 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%2Fwvgumy39u0qcams5z7x0.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%2Fwvgumy39u0qcams5z7x0.jpeg" alt="The live transcript building on screen as the words are spoken" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the prompt &lt;em&gt;is&lt;/em&gt; the parser
&lt;/h2&gt;

&lt;p&gt;Here's the part people expect to be complicated and isn't. The Lambda barely does anything — the parsing logic lives entirely in a system prompt. This is the extraction contract, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You extract items a shopper wants to buy from their spoken words.
Return ONLY a JSON array — no prose, no code fences. Each element:
{ "name": string, "quantity": number, "unit": string|null,
  "category": string|null, "note": string|null }

Rules:
- quantity defaults to 1; convert words to numbers ("a dozen" → 12, "a couple" → 2).
- category: for groceries use aisle names (Produce, Dairy, Bakery, Meat,
  Frozen, Pantry, Drinks, Household); else a short department (Electronics…).
- name is the item only, singular and title-cased ("bananas" → "Banana").
- note captures qualifiers ("ripe", "low-fat", "new") or null.
- Return [] only when the text mentions nothing to buy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model is Haiku — cheap and fast, which is exactly right for short structured extraction:&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;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-haiku-4-5&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;message&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;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&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;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&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&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because a model told to emit raw JSON will &lt;em&gt;occasionally&lt;/em&gt; wrap it in a code fence anyway, the Lambda is defensive before it trusts the output:&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;cleaned&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^``&lt;/span&gt;&lt;span class="err"&gt;`
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;endraw&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="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)?&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="sr"&gt;/i, ""&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;.replace&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="s2"&gt;```$/i, "").trim();
let parsed: ParsedItem[];
try {
  parsed = JSON.parse(cleaned);
} catch {
  throw new Error(`&lt;/span&gt;&lt;span class="nx"&gt;Model&lt;/span&gt; &lt;span class="nx"&gt;returned&lt;/span&gt; &lt;span class="nx"&gt;invalid&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$&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="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="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`);
}
if (!Array.isArray(parsed)) throw new Error("Model did not return a JSON array");

return JSON.stringify(parsed); // a.json() custom mutations return AWSJSON
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strip fences, parse, assert it's an array, and only then hand it back. If the model returns garbage, the mutation throws and the client shows an error — it does not pass mystery data downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the review gate — the whole point
&lt;/h2&gt;

&lt;p&gt;The mutation is declared to &lt;strong&gt;return&lt;/strong&gt; items, not create them:&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;parseShoppingList&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;mutation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&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="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returns&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;// ← returns parsed items…&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&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;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parseShoppingList&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="c1"&gt;// …and there is no .create() anywhere in the handler. It never persists.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the client, capture runs through a tiny state machine — the phases &lt;em&gt;are&lt;/em&gt; the UX:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;listening&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;parsing&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;review&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;empty&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;error&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;&lt;code&gt;listening&lt;/code&gt; shows the pulsing mic and transcript; &lt;code&gt;parsing&lt;/code&gt; shows a spinner while Haiku works; &lt;code&gt;review&lt;/code&gt; renders the editable list; &lt;code&gt;empty&lt;/code&gt;/&lt;code&gt;error&lt;/code&gt; are the honest failure states (heard nothing, or the parse threw). Only &lt;code&gt;review&lt;/code&gt; can lead to a write.&lt;/p&gt;

&lt;p&gt;The review sheet is where the human tap lives. Every parsed item is an editable row: rename it, step the quantity up or down, see its category chip, delete it, add a blank row by hand, or record more and append. Nothing has touched the database yet. Then:&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="nx"&gt;onAdd&lt;/span&gt;&lt;span class="o"&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;clean&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="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="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="nx"&gt;listId&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="nx"&gt;clean&lt;/span&gt; &lt;span class="p"&gt;},&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Added &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clean&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="s2"&gt; items`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&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;&lt;code&gt;addItems&lt;/code&gt; is the &lt;em&gt;only&lt;/em&gt; path that creates &lt;code&gt;ShoppingItem&lt;/code&gt; rows — a plain typed create per item, owned by the user's Cognito &lt;code&gt;sub&lt;/code&gt;. That single tap is the entire safety model: the AI's output is a draft until a human signs it.&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%2Ftnevvd4d9th70eo4nf7d.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%2Ftnevvd4d9th70eo4nf7d.jpeg" alt="The review sheet: Claude's parsed items as editable rows with quantity steppers and category chips, ready to confirm before anything saves" width="540" height="1170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: the API key that wouldn't reach the Lambda
&lt;/h2&gt;

&lt;p&gt;One dead end worth saving you. I first wired &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; through Amplify's &lt;code&gt;secret()&lt;/code&gt; — the "correct" way to handle secrets. In the sandbox, the function kept booting with an undefined key: its deploy-time secret resolution simply wasn't delivering the value to the function. I switched to injecting it as a &lt;strong&gt;plain Lambda environment variable&lt;/strong&gt;, baked at deploy from the deployer's own environment (locally via a sandbox script; on branch builds via Amplify Console env vars):&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;const&lt;/span&gt; &lt;span class="nx"&gt;parseShoppingList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineFunction&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;parse-shopping-list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./handler.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// backend.ts injects process.env.ANTHROPIC_API_KEY as a normal env var.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not glamorous, but it deployed and the key was there at runtime. (If you're keeping score with &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Post 2&lt;/a&gt;: this app and environment variables have history.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Details worth stealing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWSJSON can arrive double-encoded.&lt;/strong&gt; Depending on the layer, &lt;code&gt;a.json()&lt;/code&gt; comes back as a string, or a string &lt;em&gt;of&lt;/em&gt; a string. The client unwraps until it's an object: &lt;code&gt;while (typeof value === "string") value = JSON.parse(value);&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ShoppingItem.price&lt;/code&gt; exists but is unused in v1.&lt;/strong&gt; It's forward-compat for the Expenses tab — the category Claude infers today is what makes per-aisle spend possible later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ownership is app-layer.&lt;/strong&gt; Every item carries &lt;code&gt;userId&lt;/code&gt; = Cognito &lt;code&gt;sub&lt;/code&gt;, every read filters it, and "delete" flips a soft-delete trio rather than removing the row. The AI feature inherits all of it for free.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The obvious upgrades: stream the parse so items pop in one by one instead of after a beat, and add optimistically so the list feels instant. But the bigger thread is that reserved &lt;code&gt;price&lt;/code&gt; field — the next feature is the &lt;strong&gt;Expenses tab&lt;/strong&gt;, turning a checked-off shopping list into per-aisle spend. Claude's categories were always seeds for that.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've put an LLM in a real product: where did you draw the line between "the model suggests" and "the model acts"? I'm convinced the review gate is right for anything that writes to a user's data — but I'd like to hear where you'd let the model off the leash.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>expo</category>
      <category>react</category>
      <category>ai</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>My main branch wouldn't deploy: CloudFormation forensics on Amplify Gen 2</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Mon, 20 Jul 2026 16:03:03 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/my-main-branch-wouldnt-deploy-cloudformation-forensics-on-amplify-gen-2-380d</link>
      <guid>https://dev.to/hokagedemehin/my-main-branch-wouldnt-deploy-cloudformation-forensics-on-amplify-gen-2-380d</guid>
      <description>&lt;p&gt;My &lt;code&gt;dev&lt;/code&gt; branch deployed clean. The &lt;strong&gt;exact same commit&lt;/strong&gt; on &lt;code&gt;main&lt;/code&gt; — the merge that fast-forwarded dev into main, byte-for-byte identical backend — rolled back every single time. Amplify Hosting went red, CloudFormation logged &lt;code&gt;ROLLBACK_COMPLETE&lt;/code&gt;, and the only explanation it offered was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Validation failed with 1 error(s). Call DescribeEvents to retrieve the
full list of issues with resource and property details, resolve each
error, then retry the operation.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No resource name I could act on. No property. One error, unnamed. Here's how I found the one error — and it turned out to be a &lt;em&gt;name&lt;/em&gt; two git branches had been quietly fighting over.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — On Amplify Gen 2, &lt;code&gt;dev&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt; are branches of the &lt;strong&gt;same&lt;/strong&gt; Amplify app. If you publish an SSM parameter keyed only on the app id (&lt;code&gt;/myapp/${AWS_APP_ID}/…&lt;/code&gt;), both branches generate the identical parameter name. Whichever deploys first owns it; the second branch's deploy dies with a bare &lt;code&gt;Validation failed with 1 error(s)&lt;/code&gt; on exactly the nested stack that creates the parameter, then &lt;code&gt;ROLLBACK_COMPLETE&lt;/code&gt;. Fix: put the environment in the path (&lt;code&gt;/myapp/${AWS_APP_ID}/${env}/…&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 4 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 CloudFormation debugging story — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: one app, two branches, a shared backend
&lt;/h2&gt;

&lt;p&gt;For anyone landing here from the error text: &lt;strong&gt;AWS Amplify Gen 2&lt;/strong&gt; (CDK under the hood), deployed via &lt;code&gt;ampx pipeline-deploy&lt;/code&gt; in Amplify Hosting. One Amplify app, two branches wired to it — &lt;code&gt;dev&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt;. The backend is shared code: Cognito auth, an AppSync GraphQL API, and three DynamoDB-backed models — &lt;code&gt;UserProfile&lt;/code&gt;, &lt;code&gt;ShoppingList&lt;/code&gt;, &lt;code&gt;ShoppingItem&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One detail matters more than any other, so here it is up front. A post-confirmation Lambda needs the &lt;code&gt;UserProfile&lt;/code&gt; table &lt;strong&gt;name&lt;/strong&gt;. You can't hand a Lambda a table name as a CDK token across stacks — auth depends on data, data would depend on auth, and the deploy fails with a circular dependency. The house pattern (and AWS's recommendation) is to publish the name to &lt;strong&gt;SSM Parameter Store&lt;/strong&gt; from inside the table's own stack, and give the Lambda only the SSM &lt;em&gt;path&lt;/em&gt; string:&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;amplifyAppId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_APP_ID&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local&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;ssmPrefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/cannycart/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;amplifyAppId&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;// ← the bug lives here&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userProfileParamName&lt;/span&gt; &lt;span class="o"&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;ssmPrefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/UserProfileTableName`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StringParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userProfileTable&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UserProfileTableNameParam&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;parameterName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userProfileParamName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;stringValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userProfileTable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tableName&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;Read that &lt;code&gt;ssmPrefix&lt;/code&gt; and hold onto it. It's keyed on the app id and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  dev worked. Then I merged to main.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dev&lt;/code&gt; had been deploying happily for days. I opened a PR, merged dev into &lt;code&gt;main&lt;/code&gt;, and watched the first &lt;code&gt;main&lt;/code&gt; build. It got a long way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ Backend synthesized in 13.23 seconds
✔ Type checks completed in 15.43 seconds
✔ Built and published assets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then CloudFormation started creating resources for real. Auth stack: complete. The data stack came up and began creating the three model stacks in parallel. Two of them sailed through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATE_COMPLETE | AWS::CloudFormation::Stack | ShoppingList.NestedStackResource
CREATE_COMPLETE | AWS::CloudFormation::Stack | ShoppingItem.NestedStackResource
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the third:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATE_FAILED | AWS::CloudFormation::Stack | …UserProfileNestedStack…
  Validation failed with 1 error(s). Call DescribeEvents to retrieve the
  full list of issues…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That failure rolled straight up the tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATE_FAILED | data.NestedStackResource | Embedded stack … was not
  successfully created: The following resource(s) failed to create:
  [amplifyDataUserProfileNestedStackUserProfileNestedStackResource…].
ROLLBACK_IN_PROGRESS | amplify-…-main-branch-… | Rollback requested by user.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four minutes of &lt;code&gt;DELETE_IN_PROGRESS&lt;/code&gt; later — the whole backend, auth pool and all, torn back down — it ended where every attempt ended:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ROLLBACK_COMPLETE | AWS::CloudFormation::Stack | amplify-…-main-branch-…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same commit as dev. Green synth, green type-check. Dead on create.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory: "it's a flaky deploy, just retry"
&lt;/h2&gt;

&lt;p&gt;The rollback message literally says "Rollback requested by user," which reads like something transient — as if a retry would clear it. It won't, and that phrasing is a red herring: "user" is CloudFormation's own deployment role, and the rollback was &lt;em&gt;requested&lt;/em&gt; because a resource failed, not because anything flaked. I re-ran the build. It failed at the identical stack with the identical bare validation error. Whatever this was, it was deterministic and it was specific to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clue the log &lt;em&gt;did&lt;/em&gt; give me: only UserProfile died
&lt;/h2&gt;

&lt;p&gt;Here's the thing I almost scrolled past. Three model stacks were created in the same deploy, from the same schema, with the same auth rules. &lt;code&gt;ShoppingList&lt;/code&gt; and &lt;code&gt;ShoppingItem&lt;/code&gt; both succeeded. Only &lt;code&gt;UserProfile&lt;/code&gt; failed.&lt;/p&gt;

&lt;p&gt;If this were a broken schema or a bad credential or a region problem, all three would fail. One failing — and the &lt;em&gt;same&lt;/em&gt; one every time — means &lt;code&gt;UserProfile&lt;/code&gt; has something the other two don't. So what's different about &lt;code&gt;UserProfile&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Back to that code. &lt;code&gt;ShoppingList&lt;/code&gt; and &lt;code&gt;ShoppingItem&lt;/code&gt; are plain models. &lt;code&gt;UserProfile&lt;/code&gt; is the only stack that also creates an extra resource: the &lt;code&gt;UserProfileTableNameParam&lt;/code&gt; SSM &lt;code&gt;StringParameter&lt;/code&gt;. Of everything unique to that one stack, a named, must-be-globally-unique resource is the obvious suspect. The bare "Validation failed" was CloudFormation refusing to create a parameter — and the reason was one &lt;code&gt;DescribeEvents&lt;/code&gt; call deeper than the build log prints.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual cause: two branches, one parameter name
&lt;/h2&gt;

&lt;p&gt;The parameter name is &lt;code&gt;/cannycart/${AWS_APP_ID}/UserProfileTableName&lt;/code&gt;. &lt;code&gt;AWS_APP_ID&lt;/code&gt; is the &lt;strong&gt;Amplify app&lt;/strong&gt; id — and &lt;code&gt;dev&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt; are two branches of &lt;em&gt;the same app&lt;/em&gt;. They resolve &lt;code&gt;AWS_APP_ID&lt;/code&gt; to the identical value. So both branches try to create the exact same SSM parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/cannycart/d216bdrgjoaojz/UserProfileTableName
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dev&lt;/code&gt; deployed first and created it. When &lt;code&gt;main&lt;/code&gt; deployed and its &lt;code&gt;UserProfile&lt;/code&gt; stack tried to create the &lt;em&gt;same&lt;/em&gt; parameter name, SSM rejected it — the parameter already exists — and CloudFormation surfaced that rejection as the maddeningly generic &lt;code&gt;Validation failed with 1 error(s)&lt;/code&gt;. The final toolkit error names the culprit stack twice but still never prints the reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[CloudFormationDeploymentError] The CloudFormation deployment has failed.
  ROLLBACK_COMPLETE: Embedded stack …UserProfileNestedStack… was not
  successfully created: Validation failure detected. See the operation's
  FAILED event for details.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason lived only in the FAILED event, behind &lt;code&gt;DescribeEvents&lt;/code&gt;. The build log will point at the stack all day; it will not tell you the parameter was a duplicate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: put the environment in the path
&lt;/h2&gt;

&lt;p&gt;An app-id-only namespace assumes one deploy per app. The moment a second branch of the same app deploys, the namespace collides. The fix is to make the path unique per environment — &lt;code&gt;main&lt;/code&gt; → &lt;code&gt;prod&lt;/code&gt;, other branches → the branch name, local sandbox → your username:&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;- const ssmPrefix = `/cannycart/${amplifyAppId}`;
&lt;/span&gt;&lt;span class="gi"&gt;+ // dev and main are branches of the SAME Amplify app, so an app-id-only
+ // path collides between them. env makes it unique per environment.
+ const ssmPrefix = `/cannycart/${amplifyAppId}/${env}`;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;dev&lt;/code&gt; publishes &lt;code&gt;/cannycart/d216bdrgjoaojz/dev/UserProfileTableName&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt; publishes &lt;code&gt;/cannycart/d216bdrgjoaojz/prod/UserProfileTableName&lt;/code&gt;. No collision. &lt;code&gt;main&lt;/code&gt; deployed green on the next run.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A nested-stack rollback hides its real reason one level down.&lt;/strong&gt; &lt;code&gt;Validation failed with 1 error(s)&lt;/code&gt; in the build log is a pointer, not an answer — the actual cause is behind &lt;code&gt;DescribeEvents&lt;/code&gt; / the stack's FAILED event in the console. Go there first instead of theorizing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When N identical things deploy and only one fails, debug the difference.&lt;/strong&gt; Two model stacks succeeded and one didn't; the failing one was the only one creating a named global resource. That asymmetry was the whole case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespace shared infrastructure by environment, not just by app.&lt;/strong&gt; Any globally-unique name — SSM parameters, S3 buckets, IAM roles — keyed on &lt;code&gt;AWS_APP_ID&lt;/code&gt; alone will collide the instant a second branch of that app deploys. Put the env in the key from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"It works on dev" can mean "dev got there first."&lt;/strong&gt; A resource that another environment already created will fail silently for the next one. Shared-app, multi-branch setups make this a standing trap.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The feature this whole backend exists for: talking to your shopping list. You say "milk, a couple of bananas, a dozen eggs," and on-device speech recognition plus a Claude Haiku extraction Lambda turn it into a categorised, structured list — with a review step so a bad parse never silently pollutes your data. That's Post 5.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the most useless "helpful" error message your cloud provider has handed you? &lt;code&gt;Validation failed with 1 error(s)&lt;/code&gt; — with the one error omitted — is my current favourite.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>amplify</category>
      <category>cloudformation</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Set up React Query on an Expo Project</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:28:23 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/set-up-react-query-on-an-expo-project-5e58</link>
      <guid>https://dev.to/hokagedemehin/set-up-react-query-on-an-expo-project-5e58</guid>
      <description>&lt;p&gt;There is a good chance that you will need to interact with an external API to receive and send data, and with React Query, it makes the process easy and lets us focus on the data more than the logic of getting the data. In this blog post, we'll guide you through the steps to set up a React Native project using Expo and integrate it with React Query, ensuring you can manage your API requests efficiently.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 1: Set Up Your Expo Project
&lt;/h4&gt;

&lt;p&gt;The first step is to create a new Expo project. Expo is a framework and a platform for universal React applications, and it simplifies the process of developing and deploying React Native apps.&lt;/p&gt;

&lt;p&gt;If you have existing project then you can skip this step and move on to step 2&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
Install Expo CLI: If you haven't already, you need to install the Expo CLI. You can do this globally using npm:&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;You could use any other package manager you are comfortable with at this time, just replace the &lt;code&gt;npm install&lt;/code&gt; command with the appropriate installation instruction of your preferred package&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install -g expo-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
Create a New Expo Project: Now, create a new project using the Expo CLI:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;expo init my-react-native-query-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose a template based on your preference. &lt;/p&gt;

&lt;h4&gt;
  
  
  Step 2: Install React Query &amp;amp; Required Dependencies
&lt;/h4&gt;

&lt;p&gt;React Query is a powerful tool for managing server state in your React and React Native applications. It provides hooks to fetch, cache, and sync your data without requiring global state management.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Install React Query
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install @tanstack/react-query
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Install Required Dependencies
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npx expo install @react-native-community/netinfo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Step 3: Create Helper Functions for the project
&lt;/h4&gt;

&lt;p&gt;We will create three functions that we will need in this project. The functions will be custom hooks and we can have it saved in a subfolder called hooks at the root directory.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create useAppState.ts function
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import NetInfo from '@react-native-community/netinfo'
import { onlineManager } from '@tanstack/react-query'

onlineManager.setEventListener((setOnline) =&amp;gt; {
  return NetInfo.addEventListener((state) =&amp;gt; {
    setOnline(!!state.isConnected)
  })
})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function will perform auto-refetch anytime your reconnect your app to a working internet if you went offline &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create UseOnlineManager.ts function
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { useEffect } from 'react'
import { AppState, Platform } from 'react-native'
import type { AppStateStatus } from 'react-native'
import { focusManager } from '@tanstack/react-query'

function onAppStateChange(status: AppStateStatus) {
  if (Platform.OS !== 'web') {
    focusManager.setFocused(status === 'active')
  }
}

useEffect(() =&amp;gt; {
  const subscription = AppState.addEventListener('change', onAppStateChange)

  return () =&amp;gt; subscription.remove()
}, [])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of event listeners on &lt;code&gt;window&lt;/code&gt;, React Native provides focus information through the &lt;code&gt;AppState&lt;/code&gt; module. We will use the &lt;code&gt;AppState&lt;/code&gt; "change" event to trigger an update when the app state changes to "active"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create useRefreshOnFocus.ts function
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import React from 'react'
import { useFocusEffect } from '@react-navigation/native'

export function useRefreshOnFocus&amp;lt;T&amp;gt;(refetch: () =&amp;gt; Promise&amp;lt;T&amp;gt;) {
  const firstTimeRef = React.useRef(true)

  useFocusEffect(
    React.useCallback(() =&amp;gt; {
      if (firstTimeRef.current) {
        firstTimeRef.current = false
        return
      }

      refetch()
    }, [refetch]),
  )
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In some situations, you may want to refetch the query when a React Native Screen is focused again. This custom hook will call the provided &lt;code&gt;refetch&lt;/code&gt; function when the screen is focused again.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 4: Implement all the functions in your root file
&lt;/h4&gt;

&lt;p&gt;Whatever your main route file might be based on how your project is set up, you can add the below code right there&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import {
  QueryClient,
  QueryClientProvider,
  focusManager,
} from "@tanstack/react-query";
import { AppStateStatus, Platform } from "react-native";
import { useOnlineManager } from "@/hooks/query/useOnlineManager";
import { useAppState } from "@/hooks/query/useAppState";

export default function RootLayout() {
  function onAppStateChange(status: AppStateStatus) {
    if (Platform.OS !== "web") {
      focusManager.setFocused(status === "active");
    }
  }

  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: 2 } },
  });

  useOnlineManager();

  useAppState(onAppStateChange);
  &amp;lt;QueryClientProvider client={queryClient}&amp;gt;
    {Rest of your project}
  &amp;lt;/QueryClientProvider&amp;gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this, you'll be able to use react query in your mobile development just the same way you use it in web&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start the Development Server: In your terminal, navigate to the folder that Expo created with all of the boilerplate set-up and run
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;expo start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expo projects can be built quickly using Expo Go app that can be downloaded on your phone but it is encouraged to build a development app instead from the beginning of your project. You can see &lt;a href="https://docs.expo.dev/develop/development-builds/create-a-build/" rel="noopener noreferrer"&gt;the docs&lt;/a&gt; on how to do that.&lt;/p&gt;

&lt;h4&gt;
  
  
  Conclusion
&lt;/h4&gt;

&lt;p&gt;By setting up an Expo React Native project with React Query, you can streamline the process of managing server state and interacting with APIs. React Query's powerful features, such as caching, background updates, and synchronization, allow you to focus on what truly matters—the data, not the data-fetching logic.&lt;/p&gt;

&lt;p&gt;HAPPY CODING 👨🏼‍💻 👩🏻‍💻 💻&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>react</category>
      <category>reactnative</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Climbing the Leaderboard - HackerRank Solution (Javascript) 🚀</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:27:52 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/climbing-the-leaderboard-hackerrank-solution-javascript-2p5p</link>
      <guid>https://dev.to/hokagedemehin/climbing-the-leaderboard-hackerrank-solution-javascript-2p5p</guid>
      <description>&lt;p&gt;An arcade game player wants to climb to the top of the leaderboard and track their ranking. The game uses Dense Ranking, so its leaderboard works like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The player with the highest score is ranked number 1 on the leaderboard.&lt;/li&gt;
&lt;li&gt;Players who have equal scores receive the same ranking number, and the next player(s) receive the immediately following ranking number.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Function Description
&lt;/h3&gt;

&lt;p&gt;Complete the climbingLeaderboard function in the editor below.&lt;/p&gt;

&lt;p&gt;climbingLeaderboard has the following parameter(s):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;int ranked[n]: the leaderboard scores&lt;/li&gt;
&lt;li&gt;int player[m]: the player's scores&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Returns
&lt;/h3&gt;

&lt;p&gt;int[m]: the player's rank after each new score&lt;br&gt;
Input Format&lt;/p&gt;

&lt;p&gt;The first line contains an integer n, the number of players on the leaderboard.&lt;br&gt;
The next line contains n space-separated integers ranked[i], the leaderboard scores in decreasing order.&lt;br&gt;
The next line contains an integer, m, the number games the player plays.&lt;br&gt;
The last line contains m space-separated integers [j], the game scores.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;climbingLeaderboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Write your code here&lt;/span&gt;
    &lt;span class="c1"&gt;// Remove duplicates from the ranked list and sort in descending order&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;uniqueRanks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;ranked&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;playerRankings&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="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;uniqueRanks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;player&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;score&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;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;uniqueRanks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;index&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="nx"&gt;playerRankings&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;index&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;// Rank is index + 2 because index starts from 0 and we want the 1-based rank&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;playerRankings&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;



</description>
      <category>javascript</category>
      <category>algorithms</category>
      <category>help</category>
    </item>
    <item>
      <title>Every icon vanished: a Metro debugging story (featuring a babel package from 2017)</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:27:31 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/every-icon-vanished-a-metro-debugging-story-featuring-a-babel-package-from-2017-4ije</link>
      <guid>https://dev.to/hokagedemehin/every-icon-vanished-a-metro-debugging-story-featuring-a-babel-package-from-2017-4ije</guid>
      <description>&lt;p&gt;I added a font and a few icons. Then I ran a development build, and Metro died 2,513 modules in with an error that had nothing to do with either:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iOS Bundling failed
TypeError: Cannot read properties of undefined (reading 'transformFile')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No filename. No line number. Every build failed the same way. The trail ran through two rewrites of an icon library and ended at a &lt;code&gt;@babel/types&lt;/code&gt; published in &lt;strong&gt;2017&lt;/strong&gt; — &lt;code&gt;7.0.0-beta.4&lt;/code&gt; — sitting in my &lt;code&gt;node_modules&lt;/code&gt;, dragged in by a package I never installed on purpose. Here's the hunt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A Metro &lt;code&gt;TypeError: Cannot read properties of undefined (reading 'transformFile')&lt;/code&gt; with no filename almost always means a broken Babel toolchain, not your code. Mine was an ancient &lt;code&gt;@babel/types@7.0.0-beta.4&lt;/code&gt; (2017) resolved for the transform, pulled in transitively by &lt;code&gt;@aws-amplify/graphql-types-generator&lt;/code&gt;. Fix: pin &lt;code&gt;@babel/types&lt;/code&gt; and &lt;code&gt;@babel/generator&lt;/code&gt; (and &lt;code&gt;react-native-worklets&lt;/code&gt;) with &lt;code&gt;overrides&lt;/code&gt; in the root &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 3 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. This one's a self-contained debugging story — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The build that wouldn't build
&lt;/h2&gt;

&lt;p&gt;The setup, for anyone who lands here from a search: &lt;strong&gt;Expo SDK 57&lt;/strong&gt;, expo-router, NativeWind, with an &lt;strong&gt;Amplify Gen 2&lt;/strong&gt; backend in the same npm-workspaces monorepo. Icons come from &lt;code&gt;react-native-iconify&lt;/code&gt;, which uses a Babel plugin to scan JSX for &lt;code&gt;&amp;lt;Iconify icon="…" /&amp;gt;&lt;/code&gt; and inline the SVG at build time. I was wiring up the app's icon set and building a dev client.&lt;/p&gt;

&lt;p&gt;Metro started bundling, churned through 2,513 modules, and threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Cannot read properties of undefined (reading 'transformFile')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No file. No stack into my code. Fully reproducible. When the error names nothing you wrote, the call is coming from inside the toolchain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory #1: the icon library's Babel plugin
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;react-native-iconify&lt;/code&gt; runs during the Babel transform Metro invokes — exactly the kind of thing that can nuke &lt;code&gt;transformFile&lt;/code&gt;. And I'd recently bumped its version. Version 2 had restructured its exports: the Babel plugin moved from &lt;code&gt;./plugin&lt;/code&gt; (v1) to &lt;code&gt;./babel&lt;/code&gt; (v2), so my &lt;code&gt;babel.config.js&lt;/code&gt; reference looked like a prime suspect.&lt;/p&gt;

&lt;p&gt;I fixed the reference to match v2. The crash didn't clear — it &lt;strong&gt;moved&lt;/strong&gt;. Plausible theory, wrong theory: a broken plugin path was real, but it wasn't what was killing the bundler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory #2: it must be worklets
&lt;/h2&gt;

&lt;p&gt;Now the error mutated into something more specific, from &lt;code&gt;react-native-worklets&lt;/code&gt;' Babel plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown node TSUnknownKeyword
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So worklets was choking on a TypeScript AST node. Maybe &lt;em&gt;worklets&lt;/em&gt; was the culprit and I needed to pin it. But &lt;code&gt;TSUnknownKeyword&lt;/code&gt; is a perfectly normal node type — it exists in any modern &lt;code&gt;@babel/types&lt;/code&gt;. If a plugin claims a standard node is "unknown," the plugin isn't broken; &lt;strong&gt;it's been handed an ancient &lt;code&gt;@babel/types&lt;/code&gt; that predates that node.&lt;/strong&gt; That was the tell. I stopped chasing plugins and went looking at versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The turn: reading the lockfile
&lt;/h2&gt;

&lt;p&gt;I grepped &lt;code&gt;package-lock.json&lt;/code&gt; for every &lt;code&gt;@babel/types&lt;/code&gt; version in the tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 13 "@babel/types": "^7.29.7"
  3 "@babel/types": "^7.29.0"
  1 "@babel/types": "^7.27.1"
  # … a few more 7.2x ranges …
  1 "@babel/types": "7.0.0-beta.4"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line didn't belong. Everything modern asked for a caret range around &lt;code&gt;7.29&lt;/code&gt;. And then, alone at the bottom: &lt;code&gt;7.0.0-beta.4&lt;/code&gt;. Not a range — an &lt;strong&gt;exact pin&lt;/strong&gt;. A &lt;strong&gt;beta&lt;/strong&gt;. Of a package whose current release is 7.29. &lt;code&gt;7.0.0-beta.4&lt;/code&gt; shipped in &lt;strong&gt;2017&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 2017 beta hiding in the tree
&lt;/h2&gt;

&lt;p&gt;Who pins an eight-year-old beta of &lt;code&gt;@babel/types&lt;/code&gt;? Tracing it back, the culprit was &lt;code&gt;@aws-amplify/graphql-types-generator&lt;/code&gt; — an Amplify GraphQL codegen tool, itself a museum of old transitive deps (its own private copies of &lt;code&gt;yargs&lt;/code&gt;, &lt;code&gt;prettier&lt;/code&gt;, &lt;code&gt;camelcase&lt;/code&gt; from another era). One of them locks &lt;code&gt;@babel/types&lt;/code&gt; to that exact 2017 build.&lt;/p&gt;

&lt;p&gt;Here's the mechanism. &lt;code&gt;@babel/generator&lt;/code&gt; and the worklets plugin construct and inspect AST nodes through &lt;code&gt;@babel/types&lt;/code&gt; helpers. Between the 2017 beta and 7.29, &lt;code&gt;@babel/types&lt;/code&gt; grew an enormous number of those helpers — including builders and validators for TypeScript nodes like &lt;code&gt;TSUnknownKeyword&lt;/code&gt;. When npm's resolution let the old copy serve part of the transform, a modern plugin reached for a helper that &lt;strong&gt;didn't exist in the 2017 version&lt;/strong&gt;, got &lt;code&gt;undefined&lt;/code&gt;, and called a method on it. Metro surfaced that as &lt;code&gt;Cannot read properties of undefined (reading 'transformFile')&lt;/code&gt;. The "unknown node" error and the "transformFile" error were the same root cause wearing two masks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one &lt;code&gt;overrides&lt;/code&gt; block
&lt;/h2&gt;

&lt;p&gt;npm's &lt;code&gt;overrides&lt;/code&gt; (Yarn calls it &lt;code&gt;resolutions&lt;/code&gt;) forces a single version of a package across the entire dependency tree, no matter who asked for what. In the root &lt;code&gt;package.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"overrides"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@babel/types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.29.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@babel/generator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7.29.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"react-native-worklets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.10.0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@babel/types&lt;/code&gt; and &lt;code&gt;@babel/generator&lt;/code&gt; pinned to a matching modern pair; &lt;code&gt;react-native-worklets&lt;/code&gt; pinned to the build that plays nicely with SDK 57's reanimated. Then &lt;code&gt;rm -rf node_modules &amp;amp;&amp;amp; npm install&lt;/code&gt;. The bundle went green.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;overrides&lt;/code&gt; is a sledgehammer — it bypasses semver for &lt;em&gt;everyone&lt;/em&gt; in the tree — so it's a last resort. But when the problem is a transitive dependency four levels down pinning something incompatible, it's the only wrench that reaches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The coda: a green bundle that rendered nothing
&lt;/h2&gt;

&lt;p&gt;The bundler lived, but the icons still weren't right. iconify v2 now threw at build time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[React Native Iconify] Icon 'mdi:account-circle-outline' not found in babel.config.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v2 had changed its whole model: instead of auto-scanning JSX, it wants an explicit icon allowlist registered in an app &lt;strong&gt;entry file&lt;/strong&gt;. Under expo-router there is no classic &lt;code&gt;App.js&lt;/code&gt; entry to host that registry, so the icons never got registered.&lt;/p&gt;

&lt;p&gt;The trap was the feedback. &lt;code&gt;expo export&lt;/code&gt; completed &lt;strong&gt;successfully&lt;/strong&gt; — green, no errors — while the icons rendered as &lt;code&gt;null&lt;/code&gt; in the actual app, silently. A passing bundle told me nothing about whether a single icon would appear on screen. I confirmed the name wasn't a typo (Iconify's API answers &lt;code&gt;https://api.iconify.design/mdi.json?icons=account-circle-outline&lt;/code&gt;), then tested at the Babel-transform level and watched v2's registry simply not inject under expo-router. I downgraded to &lt;code&gt;react-native-iconify@^1.0.2&lt;/code&gt;, whose plugin auto-scans JSX and inlines the SVG with no registry and no entry file. Icons came back.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;transformFile&lt;/code&gt; error that names no file is almost never your code.&lt;/strong&gt; It's the Babel/Metro toolchain. Check the installed versions of your &lt;code&gt;@babel/*&lt;/code&gt; packages before you touch your own config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;overrides&lt;/code&gt; / &lt;code&gt;resolutions&lt;/code&gt; is the tool for transitive-dependency conflicts.&lt;/strong&gt; When something deep in the tree pins an incompatible version, this is how you reach past it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A green build is not a green render.&lt;/strong&gt; &lt;code&gt;expo export&lt;/code&gt; passing meant the bundle &lt;em&gt;compiled&lt;/em&gt;, not that the UI &lt;em&gt;worked&lt;/em&gt;. For anything that can fail silently at runtime — icons, fonts, native modules — verify on a device, not in CI logs.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The &lt;code&gt;main&lt;/code&gt; branch that refused to deploy while &lt;code&gt;dev&lt;/code&gt; worked perfectly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATE_FAILED … Validation failed with 1 error(s).
ROLLBACK_COMPLETE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's CloudFormation forensics on Amplify Gen 2, and the cause was a name two git branches were quietly fighting over. Post 4.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the oldest package you've found lurking in your &lt;code&gt;node_modules&lt;/code&gt;? &lt;code&gt;7.0.0-beta.4&lt;/code&gt; from 2017 is my record — I'd love to be beaten.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>debugging</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
