<?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: Lucas Martin</title>
    <description>The latest articles on DEV Community by Lucas Martin (@lucas_martin_8cb158b9a81b).</description>
    <link>https://dev.to/lucas_martin_8cb158b9a81b</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%2F4003983%2F363e7f88-9f64-46f6-b3c1-4a5370f4205b.png</url>
      <title>DEV Community: Lucas Martin</title>
      <link>https://dev.to/lucas_martin_8cb158b9a81b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lucas_martin_8cb158b9a81b"/>
    <language>en</language>
    <item>
      <title>What Users Actually Feel When Your AI-Generated Code Is 'Fine': A UX Autopsy of 3 Shipped Features</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:26:33 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/what-users-actually-feel-when-your-ai-generated-code-is-fine-a-ux-autopsy-of-3-shipped-features-2emc</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/what-users-actually-feel-when-your-ai-generated-code-is-fine-a-ux-autopsy-of-3-shipped-features-2emc</guid>
      <description>&lt;ul&gt;
&lt;li&gt;AI-generated code is often &lt;strong&gt;technically correct and experientially broken&lt;/strong&gt; — it solves the ticket, and the ticket rarely describes the feeling.&lt;/li&gt;
&lt;li&gt;Three real examples: a confirmation modal with no friction (14 accidental account deletions), a naked payment spinner (−4.1% completion), and an error message that blamed the user (2x refund requests).&lt;/li&gt;
&lt;li&gt;Three prompt clauses — &lt;strong&gt;emotional stakes&lt;/strong&gt;, &lt;strong&gt;latency honesty&lt;/strong&gt;, &lt;strong&gt;blame avoidance&lt;/strong&gt; — caught ~70% of these before review.&lt;/li&gt;
&lt;li&gt;A 4-item UX regression checklist for AI-authored PRs, none of which a linter can catch.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Three features shipped last quarter that passed every automated check, got merged by senior engineers, and quietly dragged our activation rate down by 6 percentage points before we caught it.&lt;/p&gt;

&lt;p&gt;All three were AI-generated. None had a bug in the traditional sense. Here's what actually went wrong.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth: &lt;strong&gt;most AI-generated code is technically correct and experientially broken.&lt;/strong&gt; The model doesn't know what a user feels at 11pm on a phone with 40% battery trying to check out. It writes the code that solves the ticket. The ticket almost never describes the feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 1: the confirmation modal that never confirmed
&lt;/h2&gt;

&lt;p&gt;Ticket: &lt;em&gt;"Add a delete-account button in settings with a confirmation modal."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What shipped:&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;Dialog&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;DialogTitle&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Delete account?&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DialogTitle&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;DialogDescription&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    This action cannot be undone.
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DialogDescription&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;DialogFooter&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;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"outline"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Cancel&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&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;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"destructive"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;deleteAccount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Delete&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&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;DialogFooter&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;Dialog&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;Technically perfect. Passed a11y checks. Matched the design system. Support tickets over the next three weeks: &lt;strong&gt;14 users accidentally deleted their accounts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What was missing: the type-your-username-to-confirm friction step. GitHub does it. Stripe does it. Every consequential delete flow on the internet does it. The model wrote what the ticket asked for, and the ticket didn't ask for friction — because the PM assumed "confirmation modal" implied it. It doesn't. To a model, a confirmation modal is exactly two buttons.&lt;/p&gt;

&lt;p&gt;User interviews afterward:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I thought Cancel was the red one because it was the destructive action."&lt;/p&gt;

&lt;p&gt;"My thumb just... hit it. I don't even remember reading the modal."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix was 8 lines. The lesson was bigger: &lt;strong&gt;the model needs the emotional stakes in the prompt, not just the functional requirements.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 2: the loading state that stole 800ms of trust
&lt;/h2&gt;

&lt;p&gt;Ticket: &lt;em&gt;"Add a loading spinner while the payment processes."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Shipped code, paraphrased:&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;{&lt;/span&gt;&lt;span class="nx"&gt;isProcessing&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;Spinner&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&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;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pay $49&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&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;Technically correct. But what the user saw:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click "Pay $49"&lt;/li&gt;
&lt;li&gt;Button disappears, spinner appears&lt;/li&gt;
&lt;li&gt;~1.2 seconds of a spinning circle with &lt;strong&gt;no context&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Success screen&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We A/B tested it against this:&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;{&lt;/span&gt;&lt;span class="nx"&gt;isProcessing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&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 items-center gap-2 text-sm text-muted-foreground"&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;Spinner&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="nt"&gt;span&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;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&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="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pay $49&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// stage cycles: "Contacting your bank..." → "Confirming payment..." → "Almost done..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The staged labels don't reflect what's happening in the backend at all — they're just paced text. Result: &lt;strong&gt;completion rate on the payment step went up 4.1%.&lt;/strong&gt; Users who felt informed didn't bounce; users who watched a naked spinner did.&lt;/p&gt;

&lt;p&gt;The model didn't know a spinner is a UX cliff. It knew a spinner was the canonical solution for a loading state. Those are different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 3: the error message that blamed the user
&lt;/h2&gt;

&lt;p&gt;Most AI-generated error handling has this shape:&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;try&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;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid input. Please check your details and try again.&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;Every word in that message pushes the failure onto the user. "Invalid input." "Check your details." "Try again."&lt;/p&gt;

&lt;p&gt;One user in a session recording:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I checked. Twice. It's not my card. Forget this." (closes tab)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The actual error, from the server logs: our idempotency key collided because they'd clicked the button twice in 300ms. Nothing was invalid. The order had gone through on their first click.&lt;/p&gt;

&lt;p&gt;Rewrite:&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;try&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;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idempotency_conflict&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;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Looks like this went through already — refreshing your order list.&lt;/span&gt;&lt;span class="dl"&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;refetchOrders&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="p"&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something on our end didn't work. Your card wasn't charged. Try again or ping support — we'll fix it.&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;Two things changed: we handled the specific error case the model glossed over, and the generic fallback puts the blame on us, not the user, while telling them their card is safe. Refund requests on that flow dropped by half.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model can't feel — and how to compensate in the prompt
&lt;/h2&gt;

&lt;p&gt;The pattern across all three: the model wrote &lt;em&gt;sufficient&lt;/em&gt; code, not &lt;em&gt;humane&lt;/em&gt; code. It optimizes for spec-completion, and specs are a lossy compression of what a good product feels like.&lt;/p&gt;

&lt;p&gt;What we added to our prompts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Emotional-stakes clause:&lt;/strong&gt; "This flow is a delete action. Real users will lose real data if they mis-click. Design the friction accordingly."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency-honesty clause:&lt;/strong&gt; "Any async call over 400ms must show a labelled progress indicator, not a naked spinner. Prefer 3 pacing labels over a single 'Loading...'."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blame-avoidance clause:&lt;/strong&gt; "Error messages must not use 'Invalid', 'Wrong', or 'You'. They must state what won't happen (charge, save, send) and what the user can do next."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those three lines in a system prompt caught roughly 70% of the UX regressions we'd been finding in review after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  A UX regression bar for AI-authored PRs
&lt;/h2&gt;

&lt;p&gt;We now run every user-facing AI PR through a 4-item checklist before merge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every destructive action has a friction step proportional to its consequence.&lt;/li&gt;
&lt;li&gt;Every async op over 400ms has a labelled progress state, not just a spinner.&lt;/li&gt;
&lt;li&gt;Every error message names what won't happen and offers a next step.&lt;/li&gt;
&lt;li&gt;Every empty state has a first-run tip, not just "No items yet."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are things a linter can catch. They're things a person who's watched 50 hours of user-session recordings can catch. Until the model has watched those recordings too, that person stays in the loop — but the checklist makes them 4x faster.&lt;/p&gt;




&lt;p&gt;If you're building a new SaaS and don't want to relearn these the hard way, most of the patterns above — typed-confirm modals, paced loading states, human error copy — are the kind of thing a mature template bakes in by default. The &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=ai-code-ux-autopsy" rel="noopener noreferrer"&gt;Applighter&lt;/a&gt; starters ship with them because they were extracted from exactly this kind of production autopsy.&lt;/p&gt;

&lt;p&gt;Which of the four checklist items would your last AI-authored PR have failed? Mine was the naked spinner — twice.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ux</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>The 4 Notification Categories Users Actually Whitelist (Based on 60 App Interviews)</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:11:00 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/the-4-notification-categories-users-actually-whitelist-based-on-60-app-interviews-15gf</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/the-4-notification-categories-users-actually-whitelist-based-on-60-app-interviews-15gf</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Users whitelist only &lt;strong&gt;four&lt;/strong&gt; notification categories: someone they know did something involving them, a thing they explicitly set up, a transaction they're expecting, and a time-critical alert from a system they trust.&lt;/li&gt;
&lt;li&gt;Everything else — feature announcements, recommendations, streaks, re-engagement, promos — gets muted within two weeks.&lt;/li&gt;
&lt;li&gt;Users pattern-match to &lt;strong&gt;category&lt;/strong&gt;, not copy. Better push copy optimizes the wrong variable.&lt;/li&gt;
&lt;li&gt;Per-category in-app consent (not one system prompt) moved opt-in from 41% to 73% and dropped 30-day mute rate from 34% to 9% in one test.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Over the last six weeks I interviewed 60 people about their phone's notification settings. I asked each to open Settings → Notifications, scroll through every app, and tell me the story behind why each one was on, off, or set to silent delivery.&lt;/p&gt;

&lt;p&gt;The pattern was sharper than I expected. Users don't allow or block push app-by-app. They do it &lt;strong&gt;category-by-category&lt;/strong&gt; — and they only tolerate four specific categories. Everything else gets muted within two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four categories users whitelist
&lt;/h2&gt;

&lt;p&gt;Across 60 interviews, people named the same four types of notification as ones they actively want:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Someone I know did something involving me.&lt;/strong&gt; A message, a comment, a mention, a friend joining, a family member sharing a photo. Nearly everyone said they never mute these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A thing I explicitly set up.&lt;/strong&gt; Alarm, reminder, calendar event, medication, workout time. The rare exceptions had turned these off &lt;em&gt;in the app itself&lt;/em&gt;, not in system settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A transaction I'm expecting.&lt;/strong&gt; Delivery updates, ride arrival, payment confirmation, booking status. Widely kept on, though several used silent delivery to soften the interruption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A time-critical alert from a system I trust.&lt;/strong&gt; Bank fraud alert, security code, appointment 15 minutes out, flight delay. &lt;em&gt;Trust&lt;/em&gt; was the load-bearing word — several people had disabled these for specific apps that had cried wolf.&lt;/p&gt;

&lt;p&gt;Every other category — feature announcements, personalized recommendations, streak reminders, re-engagement, promos, content updates — got muted, disabled, or in a few cases triggered an uninstall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why category beats copy
&lt;/h2&gt;

&lt;p&gt;The standard growth advice is "write better push copy." A/B test the emoji, tighten the CTA, personalize with the user's name.&lt;/p&gt;

&lt;p&gt;That optimizes the wrong variable.&lt;/p&gt;

&lt;p&gt;The interviews were unambiguous: users don't parse notification copy before deciding whether to keep push on. They pattern-match to the category. Once your app has sent two notifications from a category they don't want, they mute the whole app — no matter how good the copy was.&lt;/p&gt;

&lt;p&gt;So the design question isn't "how do we write a compelling notification?" It's &lt;strong&gt;"how do we ensure every notification we send falls into one of the four whitelisted categories?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The consent conversation to have in-app
&lt;/h2&gt;

&lt;p&gt;The iOS system dialog is a blunt instrument: one yes/no for all future notifications. What users actually want is category-level consent — which iOS supports through notification categories, but almost no app uses well.&lt;/p&gt;

&lt;p&gt;The pattern I now recommend:&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;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;social&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="s1"&gt;reminders&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="s1"&gt;transactions&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="s1"&gt;security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setupNotificationCategories&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;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;social&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reply&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buttonTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reply&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opensAppToForeground&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="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reminders&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;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transactions&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;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestConsentForCategory&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;Category&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;stored&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;getStoredPreferences&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="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="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&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="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// In-app modal that explains what this category is&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wantsIt&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;showCategoryConsentModal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;labelFor&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="na"&gt;example&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;exampleFor&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="na"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;frequencyFor&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="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePreference&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;wantsIt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&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="s1"&gt;denied&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;wantsIt&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;status&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;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPermissionsAsync&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;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&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;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissionsAsync&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;The in-app modal does the real work. It shows a concrete example of the notification the user would receive, states roughly how often ("a few times per week," not "occasionally"), and lets them opt in per category rather than for everything at once.&lt;/p&gt;

&lt;p&gt;One app I tested this with saw opt-in rise from &lt;strong&gt;41% to 73%&lt;/strong&gt; by moving from a single system prompt to contextual per-category prompts. More importantly, 30-day mute rate dropped from &lt;strong&gt;34% to 9%&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signal every PM misses: silent delivery
&lt;/h2&gt;

&lt;p&gt;iOS's "Deliver Quietly" lets users keep receiving your notifications but hide them from the lock screen and Notification Center. It's a soft mute — chosen when someone wants the app's data but not the interruption.&lt;/p&gt;

&lt;p&gt;Most PMs don't track it, because iOS doesn't report it. But you can infer it: if a user's open rate on pushes drops to near-zero while their app opens stay steady, they've likely moved you to Deliver Quietly.&lt;/p&gt;

&lt;p&gt;Segment those users. They're one bad broadcast away from a full mute or uninstall. For them, cut sends by 60–80% and fire only category 1 and 3 notifications (people-related and transactional). I've seen apps recover full-delivery status this way for a meaningful share of the quietly-delivered segment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redesign around user-declared intent
&lt;/h2&gt;

&lt;p&gt;The biggest shift from these interviews: the notifications users keep are tied to intent &lt;em&gt;they&lt;/em&gt; expressed inside the product. They set the reminder. They ordered the food. They followed the person. They asked the bank to alert on fraud.&lt;/p&gt;

&lt;p&gt;The notifications they mute are tied to intent the &lt;em&gt;product&lt;/em&gt; declared on their behalf. "We think you'd love this new feature." "We noticed you haven't opened us in a week." "Here's a personalized recommendation."&lt;/p&gt;

&lt;p&gt;So the implication is concrete: for every notification your app sends, you should be able to trace it back to a specific in-app action the user took that constitutes consent for &lt;em&gt;this&lt;/em&gt; notification. If you can't, it belongs in the mute pile. If you can, you're building the trust that keeps push on for the lifetime of that user.&lt;/p&gt;

&lt;p&gt;A practical way to enforce this: require a &lt;code&gt;sourceAction&lt;/code&gt; field on every notification in your schema — a machine-checkable link back to the user action that authorized the send. It's an annoying constraint at first. Then it becomes the reason your app is still on the lock screen six months in, when competitors have been muted.&lt;/p&gt;




&lt;p&gt;Run the interviews yourself. Ten users, thirty minutes each. You'll come back with a notification strategy your PM would never sign off on — and your users will actually welcome.&lt;/p&gt;

&lt;p&gt;I write more about building this kind of thing into React Native apps over at &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=notification-categories" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which of the four categories does your app mostly send? And have you ever caught yourself sending from the mute pile?&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ux</category>
      <category>mobile</category>
      <category>expo</category>
    </item>
    <item>
      <title>What OTA Updates Feel Like for Your Users (and Why It Matters)</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:29:07 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/what-ota-updates-feel-like-for-your-users-and-why-it-matters-20g9</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/what-ota-updates-feel-like-for-your-users-and-why-it-matters-20g9</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Default Expo behaviour applies updates silently at cold start. That's only good UX if app state doesn't change between bundles.&lt;/li&gt;
&lt;li&gt;Three places OTA bites: &lt;strong&gt;mid-session update prompts&lt;/strong&gt;, &lt;strong&gt;schema drift between bundle and cached state&lt;/strong&gt;, and &lt;strong&gt;delayed adoption&lt;/strong&gt; creating support burden.&lt;/li&gt;
&lt;li&gt;Fix schema drift by tagging AsyncStorage writes with a &lt;code&gt;_schemaVersion&lt;/code&gt; and migrating or evicting at startup.&lt;/li&gt;
&lt;li&gt;Expose the current bundle ID in Settings → About so support can diagnose "which bundle are you on?" in one question.&lt;/li&gt;
&lt;li&gt;The technical update should be invisible. The &lt;em&gt;experiential&lt;/em&gt; update never should be.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Every React Native OTA update tutorial focuses on the developer experience: &lt;code&gt;eas update&lt;/code&gt;, done.&lt;/p&gt;

&lt;p&gt;What almost nobody writes about is what happens on the &lt;em&gt;user's&lt;/em&gt; phone when your OTA lands, and how that experience shapes whether they stay in the app or bounce.&lt;/p&gt;

&lt;p&gt;Let's walk through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when an OTA lands
&lt;/h2&gt;

&lt;p&gt;Default Expo behaviour with &lt;code&gt;checkAutomatically: "ON_LOAD"&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User opens the app. Native shell launches, splash screen shows.&lt;/li&gt;
&lt;li&gt;In parallel, the update client checks your server for a new bundle. If found and downloaded within &lt;code&gt;fallbackToCacheTimeout&lt;/code&gt; (default &lt;code&gt;0ms&lt;/code&gt; — i.e. never wait), it's applied immediately.&lt;/li&gt;
&lt;li&gt;If the timeout hit first, the cached (previous) bundle loads. The new bundle downloads in the background and is applied on next launch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user sees nothing. That's the design goal.&lt;/p&gt;

&lt;p&gt;But "nothing" is only good UX if the state of the app doesn't change between the two bundles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three UX moments where OTA bites
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Mid-session update prompt
&lt;/h3&gt;

&lt;p&gt;You set &lt;code&gt;checkAutomatically: "ON_ERROR_RECOVERY"&lt;/code&gt; thinking you'll only update after a crash. What you actually get: a user deep in a task, the app crashes, your OTA client fetches a new bundle — and now they're looking at a re-launched app with lost context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better pattern:&lt;/strong&gt; &lt;code&gt;ON_LOAD&lt;/code&gt; (cold start only) plus an explicit in-app "Update available — restart to apply" banner for warm-session updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Schema drift between bundle and cached state
&lt;/h3&gt;

&lt;p&gt;Your new bundle expects &lt;code&gt;user.profile.avatarUrl&lt;/code&gt;. Users upgrading from an old bundle have &lt;code&gt;user.avatar&lt;/code&gt; in cache. Crash on first render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; version your cache schema. AsyncStorage writes should include a &lt;code&gt;_schemaVersion&lt;/code&gt; tag, and new bundles migrate or evict old-schema data at startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Delayed adoption and support burden
&lt;/h3&gt;

&lt;p&gt;A user emails "this feature is broken." You check the latest bundle — it's fixed. But they're on a bundle from three weeks ago, because they never re-opened the app long enough to complete the background fetch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; expose the current bundle ID in Settings → About. Support can ask "what does it say?" and immediately know whether they need to force an update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "update pending" pattern
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-updates&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;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&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="s1"&gt;react&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;useUpdatePending&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;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPending&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="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;sub&lt;/span&gt; &lt;span class="o"&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;addListener&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="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="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UpdateEventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UPDATE_AVAILABLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setPending&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="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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&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="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;Then in your app shell:&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="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;useUpdatePending&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="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UpdateBanner&lt;/span&gt; &lt;span class="na"&gt;onRestart&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="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reloadAsync&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="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;YourApp&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Show the banner unobtrusively — top of screen, dismissible. Let the user restart when they're ready.&lt;/p&gt;

&lt;p&gt;Trust beats speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Communicating rollbacks
&lt;/h2&gt;

&lt;p&gt;If you roll back a bundle, users who had the bad one for ten minutes may have taken some action in that window started a form, attempted a payment. They come back and the UI has changed under them.&lt;/p&gt;

&lt;p&gt;For any rollback that touched user-visible state, ship a small in-app notice:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We reverted an update from an hour ago. If you saw errors, they should be resolved now.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Radical honesty. Most users appreciate being told rather than left confused.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to break the invisibility rule
&lt;/h2&gt;

&lt;p&gt;For truly major changes — new navigation, a redesigned home screen — an OTA that silently swaps things is &lt;em&gt;worse&lt;/em&gt; than a store update with release notes.&lt;/p&gt;

&lt;p&gt;Consider gating major UX changes behind an in-app "What's new" modal, even if the code shipped via OTA weeks earlier.&lt;/p&gt;

&lt;p&gt;The whole point of OTA is that the technical update is invisible. The experiential update never should be.&lt;/p&gt;




&lt;p&gt;If you're starting a React Native project and want the update pipeline wired from day one, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=ota-updates-user-ux" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates Expo apps with EAS Update already configured.&lt;/p&gt;

&lt;p&gt;What's your OTA horror story? Mine was schema drift a cached object shape that crashed every returning user on first render.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ux</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Your App Store Reviewer Is a User — Design the Demo Account Like a Usability Test</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 15 Jul 2026 12:48:14 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/your-app-store-reviewer-is-a-user-design-the-demo-account-like-a-usability-test-ecg</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/your-app-store-reviewer-is-a-user-design-the-demo-account-like-a-usability-test-ecg</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Your App Store reviewer is functionally your first real user — same 30 seconds of first impression, but they can reject you.&lt;/li&gt;
&lt;li&gt;Three demo-account failures trigger rejections: an &lt;strong&gt;empty&lt;/strong&gt; account, a &lt;strong&gt;broken&lt;/strong&gt; account (stale credentials), and a &lt;strong&gt;geofenced&lt;/strong&gt; account.&lt;/li&gt;
&lt;li&gt;Seed the demo account with 10–20 realistic entries, one in-progress state, and completed onboarding.&lt;/li&gt;
&lt;li&gt;Add a review-notes walkthrough so the reviewer knows exactly what to test.&lt;/li&gt;
&lt;li&gt;Re-seed automatically before every submission — demo accounts decay.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Most indie devs think of the App Store reviewer as a QA person: they check that the app builds, opens, doesn't crash, complies with guidelines, and passes review. That's incomplete.&lt;/p&gt;

&lt;p&gt;The reviewer is also, functionally, your first real user. They have the same 30 seconds of first-impression time as anyone who downloads the app. If your demo account puts them in an empty state with no context, they judge the app the same way a real user would — and they can reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reviewers as first-user proxies
&lt;/h2&gt;

&lt;p&gt;From the reviewer's side, the process looks like this: they open your listing, install the app, launch it, and try to use it as if they'd downloaded it themselves. If your app requires auth, they use the demo account you provided.&lt;/p&gt;

&lt;p&gt;If that demo account lands them on a blank home screen with no data, they have no way to evaluate whether the app "works."&lt;/p&gt;

&lt;p&gt;Rejections in this category read as "your app did not have enough functionality to review" or "we could not test the primary features." Both are UX rejections dressed up as review outcomes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three demo-account failures
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Failure 1: the empty account.&lt;/strong&gt; The reviewer logs in and sees a blank feed, an empty list, or a "no results yet" state. There's nothing to interact with, so they can't evaluate the app's real behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 2: the broken account.&lt;/strong&gt; The demo account has stale credentials, the password was rotated, or the account was disabled. The reviewer gets "invalid password" and can't proceed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 3: the geofenced account.&lt;/strong&gt; The demo account requires a specific location, phone number, or region that the reviewer's environment doesn't match. The app looks broken through no fault of its logic.&lt;/p&gt;

&lt;p&gt;All three are preventable with about 90 minutes of prep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to seed and why
&lt;/h2&gt;

&lt;p&gt;Your demo account should contain, at minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10–20 realistic entries&lt;/strong&gt; in your primary data model (posts, tasks, contacts — whatever your app centers on).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One realistic "in-progress" state&lt;/strong&gt; — a partially filled form, a saved draft, a bookmarked item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding fully completed&lt;/strong&gt; — the reviewer shouldn't have to walk through your welcome flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At least one "friend" or connection&lt;/strong&gt; with plausible data, if the app has social features.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is simple: when the reviewer opens the app, they should see something that looks like a real user's screen, not an empty state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review-notes template
&lt;/h2&gt;

&lt;p&gt;In App Store Connect's review notes, include a block like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Demo account:
  Email:    reviewer@yourapp.com
  Password: &amp;lt;one-time-use password&amp;gt;

This account is pre-seeded with realistic data.
All primary features are accessible without in-app purchase.
Contact: your-email@yourapp.com if any issues.

Walkthrough (in case anything is unclear):
  1. Sign in with the credentials above
  2. The home screen shows your recent [items]
  3. Tap [primary CTA] to try the [key flow]
  4. Long-press [item] to see the [secondary flow]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A walkthrough like this cuts review times noticeably — the reviewer knows exactly what to test and how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the demo account healthy across releases
&lt;/h2&gt;

&lt;p&gt;The most common failure here is decay: the demo account drifts over time. New features get added that need new seed data. Passwords get rotated. The account gets used for real dev testing and its state wanders.&lt;/p&gt;

&lt;p&gt;What works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automated re-seeding&lt;/strong&gt; before each submission — a script that clears and reseeds the account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A checklist item&lt;/strong&gt; in your release process: "verify demo account signs in and shows expected data."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never use the demo account for actual dev work&lt;/strong&gt; — keep it review-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A polished demo account with seeded data plus a clear walkthrough is one of the highest-leverage things you can do for review outcomes. Treat the reviewer as a one-shot usability test — you have five minutes to convince them the app works.&lt;/p&gt;

&lt;p&gt;If you'd rather not hand-build all this from scratch, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=app-store-reviewer-demo-account" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates React Native + Expo apps with seed data and demo states you can point straight at a reviewer.&lt;/p&gt;

&lt;p&gt;What's the most avoidable App Store rejection you've hit? Drop it in the comments.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>appstore</category>
      <category>ux</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
