<?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: Hugo Rus</title>
    <description>The latest articles on DEV Community by Hugo Rus (@hugo_rus_630dd942fcf7cc62).</description>
    <link>https://dev.to/hugo_rus_630dd942fcf7cc62</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%2F4004016%2F5d302419-e375-4c77-b600-eeb065755471.png</url>
      <title>DEV Community: Hugo Rus</title>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hugo_rus_630dd942fcf7cc62"/>
    <language>en</language>
    <item>
      <title>Supabase Background Jobs, Tested Locally: Cron, Queues, HTTP from SQL, and Webhooks</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 12 Oct 2026 09:58:40 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/supabase-background-jobs-tested-locally-cron-queues-http-from-sql-and-webhooks-3jm5</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/supabase-background-jobs-tested-locally-cron-queues-http-from-sql-and-webhooks-3jm5</guid>
      <description>&lt;p&gt;Every Supabase app eventually grows a background layer. A nightly cleanup job. A digest email every morning. A queue that processes uploads one at a time. A webhook that fires when an order is paid.&lt;/p&gt;

&lt;p&gt;In Supabase, that layer is mostly SQL: &lt;code&gt;pg_cron&lt;/code&gt; schedules jobs, &lt;code&gt;pgmq&lt;/code&gt; queues work, &lt;code&gt;pg_net&lt;/code&gt; makes HTTP calls from the database (usually to an Edge Function), and database webhooks fire on inserts and updates.&lt;/p&gt;

&lt;p&gt;It's also the part of the app most teams never run locally. It's easy to push a cron job straight to the hosted project and find out the next morning that the schedule was in the wrong timezone, or that the Edge Function it calls rejected the request.&lt;/p&gt;

&lt;p&gt;This post walks through the four building blocks with working SQL, the patterns that hold up in production, and how to exercise all of it on your laptop before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four building blocks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_cron&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run SQL on a schedule&lt;/td&gt;
&lt;td&gt;Nightly cleanup, scheduled digests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_net&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Make HTTP requests from SQL&lt;/td&gt;
&lt;td&gt;Call an Edge Function from a cron job or trigger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pgmq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Durable message queue in Postgres&lt;/td&gt;
&lt;td&gt;Process work one item at a time, with retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database webhooks&lt;/td&gt;
&lt;td&gt;HTTP call on insert, update, or delete&lt;/td&gt;
&lt;td&gt;React to a row changing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most real setups combine them: &lt;strong&gt;a cron job uses &lt;code&gt;pg_net&lt;/code&gt; to call an Edge Function, which reads work from a &lt;code&gt;pgmq&lt;/code&gt; queue.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Scheduled jobs with &lt;code&gt;pg_cron&lt;/code&gt;
&lt;/h2&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;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'purge-old-sessions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- job name&lt;/span&gt;
  &lt;span class="s1"&gt;'0 3 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;-- every day at 03:00&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessions&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect jobs and their runs:&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;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&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;start_time&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;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove a job by name:&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;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unschedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'purge-old-sessions'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The timezone trap.&lt;/strong&gt; Hosted Supabase runs cron schedules in &lt;strong&gt;UTC&lt;/strong&gt;. &lt;code&gt;0 3 * * *&lt;/code&gt; is 3am UTC, which might be the middle of your users' afternoon. Write the timezone in the job name or a comment, and check it before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Calling an Edge Function from SQL with &lt;code&gt;pg_net&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Most cron jobs shouldn't do the work in SQL. They should kick off an Edge Function that can call APIs, send email, and use secrets.&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;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/send-digest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'Authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;decrypted_secret&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vault&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decrypted_secrets&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'digest_function_key'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'kind'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'daily'&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;Combine it with cron:&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;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'daily-digest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'0 8 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- 08:00 UTC&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/send-digest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;decrypted_secret&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vault&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decrypted_secrets&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'digest_function_key'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"kind":"daily"}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="err"&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;pg_net&lt;/code&gt; is asynchronous: &lt;code&gt;net.http_post&lt;/code&gt; queues the request and returns a request ID immediately. Check what came back in the response table:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_http_response&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;created&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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two rules:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep keys in Vault&lt;/strong&gt;, not in the job's SQL. Anything written inline into &lt;code&gt;cron.schedule&lt;/code&gt; is stored in plain text in &lt;code&gt;cron.job&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;net._http_response&lt;/code&gt; for failures.&lt;/strong&gt; A &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;500&lt;/code&gt; from your function won't surface anywhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Queues with &lt;code&gt;pgmq&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When work needs to happen reliably, one item at a time, with retries if something fails, use a queue instead of a bare cron job.&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;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Producer: enqueue work (from a trigger, an RPC, or app code)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"image_id": "abc123"}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A worker (usually an Edge Function called on a schedule) reads a batch:&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="c1"&gt;-- Read up to 10 messages, hiding them from other readers for 60 seconds&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_ct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a message is processed, delete or archive it:&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;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;-- done, discard&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;43&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;-- done, keep a record&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the worker crashes before deleting a message, the &lt;strong&gt;visibility timeout&lt;/strong&gt; (60 seconds above) expires and the message becomes readable again. That's your retry. Use &lt;code&gt;read_ct&lt;/code&gt; to spot messages that keep failing and move them aside after a few attempts, so one bad message doesn't block the queue forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Database webhooks
&lt;/h2&gt;

&lt;p&gt;Webhooks call a URL when rows change. The payload has a fixed shape your Edge Function can rely on:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INSERT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"record"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"o1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u1"&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;"old_record"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&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;For &lt;code&gt;UPDATE&lt;/code&gt;, both &lt;code&gt;record&lt;/code&gt; and &lt;code&gt;old_record&lt;/code&gt; are set. For &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;record&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make handlers idempotent.&lt;/strong&gt; Deliveries can be retried, and an &lt;code&gt;UPDATE&lt;/code&gt; can fire even when the column you care about didn't change. Compare &lt;code&gt;record&lt;/code&gt; and &lt;code&gt;old_record&lt;/code&gt; before sending a receipt, and record what you've already processed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing all of it locally
&lt;/h2&gt;

&lt;p&gt;The usual options for running this before deploying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Supabase CLI&lt;/strong&gt; (&lt;code&gt;supabase start&lt;/code&gt;) runs the full Docker stack, including these extensions. It's the highest-fidelity local option, at about a dozen containers and roughly 1.4 GB of RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=supabase_background_jobs_2026" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt;&lt;/strong&gt;, an open-source, Supabase-compatible backend that runs as one process with no Docker. It emulates &lt;code&gt;pg_cron&lt;/code&gt;, &lt;code&gt;pgmq&lt;/code&gt;, &lt;code&gt;pg_net&lt;/code&gt;, Vault, and database webhooks, so the SQL above runs as written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A local test loop with tinbase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tinbase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put your jobs in a migration so every environment gets the same schedule:&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="c1"&gt;-- supabase/migrations/20261012000000_background_jobs.sql&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'process-images'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'* * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'http://127.0.0.1:54321/functions/v1/process-images'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&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 the Studio-style dashboard's SQL editor or from a test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enqueue a few messages with &lt;code&gt;pgmq.send&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Wait a minute for the cron job to fire.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;cron.job_run_details&lt;/code&gt; for the run.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;net._http_response&lt;/code&gt; for the function's status code.&lt;/li&gt;
&lt;li&gt;Check that the queue is empty (or the bad message is still there with a higher &lt;code&gt;read_ct&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Edge Functions written with &lt;code&gt;Deno.serve&lt;/code&gt; run in-process from &lt;code&gt;supabase/functions/&lt;/code&gt;, so the cron → &lt;code&gt;pg_net&lt;/code&gt; → function → queue loop happens entirely on your laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to know about the emulation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It implements the surface real projects call, not every function each extension offers. If you use something exotic, check tinbase's compatibility table.&lt;/li&gt;
&lt;li&gt;It's alpha, and built for local development and CI, not production.&lt;/li&gt;
&lt;li&gt;Edge Functions with &lt;code&gt;npm:&lt;/code&gt; or &lt;code&gt;jsr:&lt;/code&gt; imports need bundling first.&lt;/li&gt;
&lt;li&gt;Run your jobs once against the real Supabase CLI or a staging project before deploying, especially anything time-sensitive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist before shipping background jobs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Jobs are defined in migrations, not typed into the dashboard&lt;/li&gt;
&lt;li&gt;[ ] Every schedule's timezone is written down (hosted cron runs in UTC)&lt;/li&gt;
&lt;li&gt;[ ] Function keys live in Vault, never inline in &lt;code&gt;cron.schedule&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Someone (or something) checks &lt;code&gt;net._http_response&lt;/code&gt; for errors&lt;/li&gt;
&lt;li&gt;[ ] Queue workers delete or archive every processed message&lt;/li&gt;
&lt;li&gt;[ ] Messages that fail repeatedly are moved aside using &lt;code&gt;read_ct&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Webhook handlers are idempotent and compare &lt;code&gt;record&lt;/code&gt; with &lt;code&gt;old_record&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] The whole loop has run at least once before it reaches production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Background jobs fail quietly, at 3am, in UTC. Running them on your laptop first is the cheapest insurance you can buy.&lt;/p&gt;

&lt;p&gt;What's the background job that bit you hardest? I'd like to collect the war stories.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Own Expo Boilerplate in 60 Minutes: The Day-One Setup I Wish I'd Done Every Time</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Thu, 08 Oct 2026 14:45:25 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/your-own-expo-boilerplate-in-60-minutes-the-day-one-setup-i-wish-id-done-every-time-3de8</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/your-own-expo-boilerplate-in-60-minutes-the-day-one-setup-i-wish-id-done-every-time-3de8</guid>
      <description>&lt;p&gt;Every Expo project I've regretted had the same origin story: &lt;code&gt;npx create-expo-app&lt;/code&gt;, straight into building screens, and "I'll set up the boring stuff later." Later arrived as a broken production build, secrets in the bundle, a crash nobody could reproduce, and a weekend of retrofitting.&lt;/p&gt;

&lt;p&gt;The boring stuff takes about an hour on day one. On day ninety it takes a week. This is the checklist I now run before writing a single feature, in order, with the commands. At the end you have your own boilerplate: a repo you can copy for every new app.&lt;/p&gt;

&lt;h2&gt;
  
  
  0. Start from the current template (2 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-expo-app@latest my-app
&lt;span class="nb"&gt;cd &lt;/span&gt;my-app
npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default template already gives you TypeScript, Expo Router, and an &lt;code&gt;@/*&lt;/code&gt; path alias. Run &lt;code&gt;expo-doctor&lt;/code&gt; immediately and fix anything it flags while the project is still empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Make TypeScript strict (5 minutes)
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;tsconfig.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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extends"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expo/tsconfig.base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&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;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"noUncheckedIndexedAccess"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"paths"&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;"@/*"&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="s2"&gt;"./*"&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a script so CI and you run the same check:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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;"typecheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsc --noEmit"&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="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;Turning strict mode on later means fixing hundreds of errors at once. Turning it on now means fixing zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Linting and formatting (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo lint          &lt;span class="c"&gt;# sets up ESLint with Expo's config on first run&lt;/span&gt;
npm i &lt;span class="nt"&gt;-D&lt;/span&gt; prettier eslint-config-prettier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the rules that catch real bugs, not style debates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; as an error&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@typescript-eslint/no-explicit-any&lt;/code&gt; as an error&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no-restricted-imports&lt;/code&gt; for &lt;code&gt;@react-navigation/*&lt;/code&gt;, so navigation always comes from &lt;code&gt;expo-router&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Decide the folder structure before it decides itself (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/                  # routes only (Expo Router)
components/           # reusable UI
features/&amp;lt;name&amp;gt;/      # screens' logic, hooks, and components per feature
lib/                  # clients (supabase, api), utilities
queries/              # data-fetching hooks
theme/                # colors, spacing, typography tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule that matters most: &lt;strong&gt;&lt;code&gt;app/&lt;/code&gt; contains routes, not logic.&lt;/strong&gt; A route file imports a screen from &lt;code&gt;features/&lt;/code&gt; and renders it. Routes stay thin, and features stay testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Theme tokens before the first screen (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// theme/tokens.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;colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0B0B0F&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#16161D&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#F5F5F7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#9A9AA3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#3B82F6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;danger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#EF4444&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;space&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whether you use NativeWind, StyleSheet, or a UI kit, the tokens live in one place. When the designer changes the primary color, it's one line, not a search across forty files.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Data fetching that handles loading and errors (5 minutes)
&lt;/h2&gt;



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

&lt;/div&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="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="nx"&gt;QueryClientProvider&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;@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="k"&gt;import&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;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-router&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;queryClient&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;QueryClient&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QueryClientProvider&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="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&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;QueryClientProvider&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then make it a rule: no &lt;code&gt;fetch&lt;/code&gt; or database calls inside &lt;code&gt;useEffect&lt;/code&gt; in screens. Every request goes through a hook in &lt;code&gt;queries/&lt;/code&gt;, which gives you caching, retries, and loading and error states for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Secure storage for anything sensitive (3 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-secure-store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Session tokens and refresh tokens go in the Keychain (iOS) or Keystore (Android) via &lt;code&gt;expo-secure-store&lt;/code&gt;, not in AsyncStorage. Set the pattern now and every auth library you add later plugs into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Environment variables, the safe way (5 minutes)
&lt;/h2&gt;

&lt;p&gt;Two kinds of values, two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public config&lt;/strong&gt; (API URL, publishable keys): &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; variables, which are inlined into the JavaScript bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets&lt;/strong&gt; (third-party API keys, service keys): never in the app. They live on your server or in a serverless function the app calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store build-time values in EAS so every machine and CI run uses the same ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; eas-cli
eas init
eas &lt;span class="nb"&gt;env&lt;/span&gt;:create &lt;span class="nt"&gt;--environment&lt;/span&gt; production &lt;span class="nt"&gt;--name&lt;/span&gt; EXPO_PUBLIC_API_URL &lt;span class="nt"&gt;--value&lt;/span&gt; https://api.example.com
eas &lt;span class="nb"&gt;env&lt;/span&gt;:pull &lt;span class="nt"&gt;--environment&lt;/span&gt; development
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a CI check that fails if anything like &lt;code&gt;EXPO_PUBLIC_.*SECRET&lt;/code&gt; appears in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Build profiles and a development build (10 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-dev-client
eas build:configure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up three profiles in &lt;code&gt;eas.json&lt;/code&gt;, each linked to its environment:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cli"&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;"appVersionSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remote"&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;"build"&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;"development"&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;"developmentClient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"development"&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;"preview"&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;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preview"&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;"production"&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;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"autoIncrement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then make your first development build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas build:dev &lt;span class="nt"&gt;--platform&lt;/span&gt; ios
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here on, you develop in your own dev client instead of Expo Go. Push notifications, payments, and most native modules don't work in Expo Go, so finding out on day one beats finding out the week before launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Over-the-air updates, configured safely (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-updates
eas update:configure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;app.json&lt;/code&gt;, use the fingerprint runtime policy so an update only reaches binaries that can run it:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&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;"runtimeVersion"&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;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fingerprint"&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a JavaScript-only bug fix is a five-minute publish instead of a store review.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Crash reporting before the first user (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sentry/wizard@latest &lt;span class="nt"&gt;-i&lt;/span&gt; reactNative
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wizard adds the SDK, the config plugin, and source map uploads during EAS builds. A crash report with a readable stack trace is the difference between "fixed in an hour" and "can't reproduce."&lt;/p&gt;

&lt;h2&gt;
  
  
  11. CI that runs on every pull request (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/ci.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;22&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;npm&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run typecheck&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx expo lint&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx expo install --check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three checks: types, lint, and dependency versions that match your Expo SDK. That last one catches the "works locally, fails on EAS" class of bugs before they cost a build.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. The two screens every app needs (optional, but cheap now)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Settings → Account → Delete account.&lt;/strong&gt; Both app stores require in-app account deletion if users can sign up. Add the route now, even if the button is a stub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error and empty states.&lt;/strong&gt; A shared &lt;code&gt;&amp;lt;ErrorState /&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;EmptyState /&amp;gt;&lt;/code&gt; component means every screen handles failure the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Save it as your boilerplate
&lt;/h2&gt;

&lt;p&gt;Once all twelve are done, commit the repo, tag it, and mark it as a GitHub template repository. Every new app starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh repo create my-next-app &lt;span class="nt"&gt;--template&lt;/span&gt; you/expo-boilerplate &lt;span class="nt"&gt;--private&lt;/span&gt; &lt;span class="nt"&gt;--clone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the template whenever you upgrade the Expo SDK, and every future project starts current.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Fresh template, &lt;code&gt;expo-doctor&lt;/code&gt; clean&lt;/li&gt;
&lt;li&gt;[ ] Strict TypeScript and a &lt;code&gt;typecheck&lt;/code&gt; script&lt;/li&gt;
&lt;li&gt;[ ] ESLint and Prettier with bug-catching rules&lt;/li&gt;
&lt;li&gt;[ ] Folder structure: routes in &lt;code&gt;app/&lt;/code&gt;, logic in &lt;code&gt;features/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Theme tokens in one file&lt;/li&gt;
&lt;li&gt;[ ] TanStack Query for all data fetching&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;expo-secure-store&lt;/code&gt; for tokens&lt;/li&gt;
&lt;li&gt;[ ] Public vs secret env vars separated; values on EAS&lt;/li&gt;
&lt;li&gt;[ ] Three build profiles and a development build&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;expo-updates&lt;/code&gt; with fingerprint runtime version&lt;/li&gt;
&lt;li&gt;[ ] Sentry with source maps&lt;/li&gt;
&lt;li&gt;[ ] CI: typecheck, lint, dependency check&lt;/li&gt;
&lt;li&gt;[ ] Account deletion route and shared error/empty states&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;About an hour of work, and every app you build afterward starts from a foundation instead of a to-do list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you'd rather skip straight to screens, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=expo_boilerplate_day_one_2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates Expo apps as real React Native code you own, so you can drop them into a setup like this one and keep going.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What's on your day-one list that isn't on mine?&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>typescript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>React Native Accessibility: The 2026 WCAG 2.2 Compliance Guide</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 05 Oct 2026 11:00:29 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-accessibility-the-2026-wcag-22-compliance-guide-56ja</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-accessibility-the-2026-wcag-22-compliance-guide-56ja</guid>
      <description>&lt;p&gt;Most React Native apps fail accessibility audits for the same dozen reasons, and almost none of them are hard to fix. An unlabeled icon button. A tap target that's 28 points wide. A modal that VoiceOver can read straight through. Text that clips at 200% font size.&lt;/p&gt;

&lt;p&gt;This guide maps WCAG 2.2 Level AA to the React Native APIs that satisfy it, with code for each. If you build to WCAG 2.2 AA, you also meet WCAG 2.1 AA, because 2.2 is backwards compatible and only adds criteria (plus removes one obsolete one, 4.1.1 Parsing).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not legal advice.&lt;/strong&gt; If you're building for a government agency, a public university, or a healthcare provider, confirm your specific obligations with counsel.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this matters in 2026
&lt;/h2&gt;

&lt;p&gt;The U.S. Department of Justice's ADA Title II rule makes &lt;strong&gt;WCAG 2.1 Level AA&lt;/strong&gt; the technical standard for web content and mobile apps provided by state and local governments, including public schools, universities, courts, transit authorities, and public hospitals. That includes apps built for them by contractors.&lt;/p&gt;

&lt;p&gt;The deadlines moved. On April 20, 2026, four days before the original deadline, DOJ published an interim final rule extending them by a year:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Public entity&lt;/th&gt;
&lt;th&gt;Original deadline&lt;/th&gt;
&lt;th&gt;Current deadline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Population 50,000 or more&lt;/td&gt;
&lt;td&gt;April 24, 2026&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;April 26, 2027&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Population under 50,000, and special districts&lt;/td&gt;
&lt;td&gt;April 26, 2027&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;April 26, 2028&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things the extension didn't change. First, it only delays the WCAG compliance dates; Title II's underlying nondiscrimination obligations still apply today, and those have supported accessibility lawsuits for years. Second, the separate HHS Section 504 rule for recipients of HHS funding kept its May 11, 2026 first deadline.&lt;/p&gt;

&lt;p&gt;And outside the U.S., the European Accessibility Act has applied to many consumer-facing apps (banking, e-commerce, transport, e-books) since June 28, 2025.&lt;/p&gt;

&lt;p&gt;Even without a legal requirement, roughly one in six people worldwide lives with a significant disability. Accessibility is a big chunk of your addressable market.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Label everything a screen reader can focus
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 1.1.1 Non-text Content (A), 4.1.2 Name, Role, Value (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The single most common failure: icon-only buttons with no label. VoiceOver reads "button." TalkBack reads "unlabeled."&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;// ❌ Screen reader says: "button"&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="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="nx"&gt;onDelete&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;TrashIcon&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;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Screen reader says: "Delete note, button. Removes this note permanently."&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="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="nx"&gt;onDelete&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Delete note"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityHint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Removes this note permanently"&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;TrashIcon&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;Pressable&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;Rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Labels describe what it is&lt;/strong&gt;, not what it looks like. "Delete note," not "trash can icon."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't put the role in the label.&lt;/strong&gt; "Delete button" becomes "Delete button, button."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hints are optional&lt;/strong&gt; and describe the result. Use them only when the label isn't enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decorative images&lt;/strong&gt; should be hidden: &lt;code&gt;accessible={false}&lt;/code&gt; on the &lt;code&gt;Image&lt;/code&gt;, or wrap them in a parent that sets &lt;code&gt;importantForAccessibility="no"&lt;/code&gt; on Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since React Native 0.71 you can also use the web-style &lt;code&gt;aria-*&lt;/code&gt; props and &lt;code&gt;role&lt;/code&gt;, which map to the same native properties:&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;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Delete note"&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="nx"&gt;onDelete&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;TrashIcon&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;Pressable&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;Pick one style per codebase and lint for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Expose state, not just labels
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 4.1.2 Name, Role, Value (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A checkbox that looks checked but announces nothing about its state fails, even with a perfect label.&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;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"checkbox"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityState&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="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isDone&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Buy groceries"&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="nx"&gt;toggle&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;Checkbox&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;isDone&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="p"&gt;&amp;gt;&lt;/span&gt;Buy groceries&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;Pressable&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;Other states worth wiring up: &lt;code&gt;selected&lt;/code&gt; for tabs and segmented controls, &lt;code&gt;expanded&lt;/code&gt; for accordions, &lt;code&gt;disabled&lt;/code&gt; for inactive buttons, and &lt;code&gt;busy&lt;/code&gt; while something loads.&lt;/p&gt;

&lt;p&gt;For sliders and progress bars, use &lt;code&gt;accessibilityValue&lt;/code&gt;:&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;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"adjustable"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Volume"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityValue&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="na"&gt;min&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="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;volume&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityActions&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="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="s1"&gt;increment&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decrement&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="na"&gt;onAccessibilityAction&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="nx"&gt;e&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;setVolume&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="o"&gt;=&amp;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;nativeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;increment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&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="nx"&gt;v&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="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;Without &lt;code&gt;accessibilityActions&lt;/code&gt;, screen reader users can't change the value at all; swipe up and down on an adjustable element does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Group related content
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 1.3.1 Info and Relationships (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A product card with an image, title, price, and rating becomes five separate swipe stops by default. Group it so it reads as one sentence:&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;accessible&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;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;price&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, rated &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; out of 5`&lt;/span&gt;&lt;span class="si"&gt;}&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="nx"&gt;openProduct&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;Image&lt;/span&gt; &lt;span class="na"&gt;source&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="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;photo&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;&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;name&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="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;price&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StarRating&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;rating&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;Pressable&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;Setting &lt;code&gt;accessible&lt;/code&gt; on the parent makes it a single focusable element. The label you write replaces the children's text, so make sure it includes everything that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Make tap targets big enough
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.5.8 Target Size (Minimum), new in 2.2 (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;WCAG 2.2 requires targets of at least &lt;strong&gt;24×24 CSS pixels&lt;/strong&gt;, with spacing exceptions. That's the legal floor. Apple's Human Interface Guidelines recommend 44×44 points and Material Design recommends 48×48 dp; aim for those.&lt;/p&gt;

&lt;p&gt;You don't have to make the icon bigger. Use &lt;code&gt;hitSlop&lt;/code&gt; to expand the touchable area:&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="nx"&gt;onClose&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Close"&lt;/span&gt;
  &lt;span class="na"&gt;hitSlop&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="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// 20pt icon → 44pt target&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;CloseIcon&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;20&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;Pressable&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;Watch for adjacent targets: two 24-point icons touching each other technically pass the size rule but are still hard to hit. Leave space between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Provide alternatives to dragging
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.5.7 Dragging Movements, new in 2.2 (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a feature only works by dragging (reordering a list, a swipe-to-delete row, a range slider), there must be a single-pointer alternative. Typical fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Swipe-to-delete → also show a delete action in a long-press menu or an edit mode.&lt;/li&gt;
&lt;li&gt;Drag-to-reorder → add "Move up" and "Move down" buttons, or expose them as accessibility actions:
&lt;/li&gt;
&lt;/ul&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;accessible&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityActions&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="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="s1"&gt;moveUp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Move up&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;moveDown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Move down&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="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onAccessibilityAction&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="nx"&gt;e&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;move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nativeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionName&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;Screen reader users reach these through the actions rotor on iOS and the actions menu on Android.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Keep focus visible and unobscured
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.4.7 Focus Visible (AA), 2.4.11 Focus Not Obscured (Minimum), new in 2.2 (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mobile users navigate with keyboards, switch control, and external devices more than most developers assume. The rule in 2.2: when an element receives focus, it can't be completely hidden behind something else, like a sticky footer, a floating action button, or a cookie banner.&lt;/p&gt;

&lt;p&gt;In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't remove focus indicators on &lt;code&gt;TextInput&lt;/code&gt; and custom buttons.&lt;/li&gt;
&lt;li&gt;Pad scroll views so the last focusable item can scroll clear of a sticky bottom bar (&lt;code&gt;contentContainerStyle={{ paddingBottom: footerHeight }}&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Test with a hardware keyboard on iPad and with Switch Access on Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Trap focus in modals
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.4.3 Focus Order (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a modal opens, screen reader focus should move into it and stay there. React Native's built-in &lt;code&gt;Modal&lt;/code&gt; handles this on both platforms. Custom overlays (a bottom sheet built from an absolutely positioned &lt;code&gt;View&lt;/code&gt;) usually don't.&lt;/p&gt;

&lt;p&gt;For custom overlays, hide everything behind them:&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;importantForAccessibility&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sheetOpen&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-hide-descendants&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;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// Android&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityElementsHidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sheetOpen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;                                  &lt;span class="c1"&gt;// iOS&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;MainContent&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sheetOpen&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;View&lt;/span&gt; &lt;span class="na"&gt;accessibilityViewIsModal&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;/* iOS: ignore siblings */&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;BottomSheetContent&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;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then move focus to the sheet's heading when it opens:&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;headingRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&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="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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sheetOpen&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;headingRef&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findNodeHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headingRef&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAccessibilityFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&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;sheetOpen&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Announce changes nobody can see
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 4.1.3 Status Messages (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Item added to cart," "Saved," "3 results found." Sighted users see a toast; screen reader users hear nothing unless you announce it.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AccessibilityInfo&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-native&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;addToCart&lt;/span&gt; &lt;span class="o"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToCart&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;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;announceForAccessibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Added to cart&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;On Android you can also mark a region as live so updates are read automatically:&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;Text&lt;/span&gt; &lt;span class="na"&gt;accessibilityLiveRegion&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"polite"&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;resultCount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; results&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  9. Support text scaling to 200%
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 1.4.4 Resize Text (AA), 1.4.10 Reflow (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;React Native scales &lt;code&gt;Text&lt;/code&gt; with the system font size by default. The failures come from layouts that can't handle it: fixed heights, single-line rows, and text that clips.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't disable scaling&lt;/strong&gt; with &lt;code&gt;allowFontScaling={false}&lt;/code&gt;. If a specific element genuinely can't grow (a tab bar label), cap it with &lt;code&gt;maxFontSizeMultiplier={1.5}&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid fixed heights&lt;/strong&gt; on containers that hold text. Use &lt;code&gt;minHeight&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let rows wrap.&lt;/strong&gt; &lt;code&gt;flexWrap: 'wrap'&lt;/code&gt; or a column layout at large sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test at the largest accessibility size&lt;/strong&gt;, not just the largest standard size. On iOS that's Settings → Accessibility → Display &amp;amp; Text Size → Larger Text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Meet contrast minimums
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 1.4.3 Contrast Minimum (AA), 1.4.11 Non-text Contrast (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4.5:1&lt;/strong&gt; for body text&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3:1&lt;/strong&gt; for large text (18pt, or 14pt bold) and for UI components like input borders, icons, and focus indicators&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual offenders are light grey placeholder text, disabled-looking secondary buttons that aren't actually disabled, and thin icon strokes on colored backgrounds. Check both light and dark mode; a palette that passes in one often fails in the other.&lt;/p&gt;

&lt;p&gt;Also: &lt;strong&gt;don't use color alone&lt;/strong&gt; to convey meaning (1.4.1). An error field needs an icon or text, not just a red border.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Respect reduced motion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.3.3 Animation from Interactions (AAA, but widely expected)&lt;/strong&gt;&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AccessibilityInfo&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-native&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;reduceMotion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setReduceMotion&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="nx"&gt;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isReduceMotionEnabled&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;setReduceMotion&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;AccessibilityInfo&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reduceMotionChanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setReduceMotion&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use Reanimated, &lt;code&gt;useReducedMotion()&lt;/code&gt; does the same in one line. When reduce motion is on, replace slides and parallax with fades or instant transitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Don't make people re-type or memorize
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 3.3.7 Redundant Entry (A), 3.3.8 Accessible Authentication (Minimum) (AA), both new in 2.2&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redundant Entry:&lt;/strong&gt; if a user already entered their address in step 1 of checkout, prefill it in step 3 or offer "same as shipping."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessible Authentication:&lt;/strong&gt; don't block password managers or paste. Set the right content types so autofill works:
&lt;/li&gt;
&lt;/ul&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;TextInput&lt;/span&gt;
  &lt;span class="na"&gt;textContentType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"emailAddress"&lt;/span&gt;      &lt;span class="c1"&gt;// iOS&lt;/span&gt;
  &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;                &lt;span class="c1"&gt;// Android&lt;/span&gt;
  &lt;span class="na"&gt;keyboardType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email-address"&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;TextInput&lt;/span&gt;
  &lt;span class="na"&gt;textContentType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;
  &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;
  &lt;span class="na"&gt;secureTextEntry&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;TextInput&lt;/span&gt;
  &lt;span class="na"&gt;textContentType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"oneTimeCode"&lt;/span&gt;       &lt;span class="c1"&gt;// iOS SMS code autofill&lt;/span&gt;
  &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sms-otp"&lt;/span&gt;              &lt;span class="c1"&gt;// Android&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;Avoid CAPTCHAs that require transcribing distorted text. Passkeys, magic links, and biometrics all satisfy this criterion.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Keep help in the same place
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 3.2.6 Consistent Help, new in 2.2 (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your app offers help (a chat button, a support link, a phone number), put it in the same relative place on every screen that has it. A help icon in the header on one screen and in a settings submenu on another fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing: what to actually do
&lt;/h2&gt;

&lt;p&gt;Automated tools catch maybe a third of issues. The rest needs a human with a screen reader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lint in CI.&lt;/strong&gt; &lt;code&gt;eslint-plugin-react-native-a11y&lt;/code&gt; flags missing roles, labels, and invalid states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-react-native-a11y
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extends"&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="s2"&gt;"plugin:react-native-a11y/all"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test by role in unit tests.&lt;/strong&gt; React Native Testing Library queries by role and label, so a test that can't find the button is also a screen reader that can't:&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;deleteBtn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Delete note&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;fireEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deleteBtn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Run the platform tools.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iOS: Accessibility Inspector in Xcode (audit any simulator screen)&lt;/li&gt;
&lt;li&gt;Android: Accessibility Scanner app from Google Play&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Test manually, once per release, on real devices:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Navigate every core flow with VoiceOver (iOS) and TalkBack (Android), eyes closed&lt;/li&gt;
&lt;li&gt;[ ] Every interactive element has a sensible label and role&lt;/li&gt;
&lt;li&gt;[ ] Modals and sheets trap focus; closing returns focus to the trigger&lt;/li&gt;
&lt;li&gt;[ ] Status changes are announced&lt;/li&gt;
&lt;li&gt;[ ] Largest text size: nothing clips, overlaps, or disappears&lt;/li&gt;
&lt;li&gt;[ ] Contrast passes in both light and dark mode&lt;/li&gt;
&lt;li&gt;[ ] Every drag interaction has a tap alternative&lt;/li&gt;
&lt;li&gt;[ ] Reduce motion is respected&lt;/li&gt;
&lt;li&gt;[ ] Password managers and SMS code autofill work&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start with the 20% that fixes 80%
&lt;/h2&gt;

&lt;p&gt;If you only have a day, do these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Label every icon-only button.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;hitSlop&lt;/code&gt; to every small target.&lt;/li&gt;
&lt;li&gt;Fix text clipping at the largest font size.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;accessibilityState&lt;/code&gt; to every checkbox, switch, and tab.&lt;/li&gt;
&lt;li&gt;Announce toasts with &lt;code&gt;announceForAccessibility&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That clears the majority of failures in a typical audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it in from the start
&lt;/h2&gt;

&lt;p&gt;Retrofitting accessibility is far more expensive than starting with it. If you're generating new screens, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rn_accessibility_wcag_2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; produces React Native and Expo code you own, so you can add the patterns above, lint for them, and keep them consistent as the app grows.&lt;/p&gt;

&lt;p&gt;What's the accessibility bug you see most often in React Native apps? Mine is still the unlabeled icon button, every single time.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>a11y</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Code Review Checklist for AI-Generated React Native (10 Things Agents Get Wrong)</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:44:46 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/the-code-review-checklist-for-ai-generated-react-native-10-things-agents-get-wrong-1lkf</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/the-code-review-checklist-for-ai-generated-react-native-10-things-agents-get-wrong-1lkf</guid>
      <description>&lt;p&gt;AI-generated React Native code has a specific failure profile. It compiles. It renders. The screenshot looks right. And then a reviewer who's shipped a few apps opens the PR and finds the same ten problems they found in the last one.&lt;/p&gt;

&lt;p&gt;That predictability is useful. If agents make the same mistakes, you can review for them mechanically, and you can stop most of them before they're written. This is the checklist I use when reviewing Claude Code, Cursor, or Codex output in Expo projects, with the fix for each and, where possible, a lint rule that catches it automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Data fetching inside &lt;code&gt;useEffect&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The most common pattern in generated code:&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="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="nx"&gt;supabase&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&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;data&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;setNotes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No loading state, no error state, no refetch, no cache, and a race condition if the component remounts. The fix is a query library:&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="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;notes&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="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&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="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&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="o"&gt;=&amp;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;supabase&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;data&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; an ESLint &lt;code&gt;no-restricted-syntax&lt;/code&gt; rule that flags &lt;code&gt;supabase.from&lt;/code&gt; inside &lt;code&gt;useEffect&lt;/code&gt;, or a &lt;code&gt;CLAUDE.md&lt;/code&gt; line: "All data fetching goes through TanStack Query hooks in &lt;code&gt;src/queries/&lt;/code&gt;."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Secrets in &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; variables
&lt;/h2&gt;

&lt;p&gt;Agents fix a failing API call by moving the key to where it "works":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;EXPO_PUBLIC_OPENAI_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-...   &lt;span class="c"&gt;# shipped to every user's device&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything prefixed &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; is inlined into the JS bundle. Third-party keys belong in an Edge Function; the app calls the function with the user's session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; a CI grep for &lt;code&gt;EXPO_PUBLIC_.*(KEY|SECRET|TOKEN)&lt;/code&gt; that fails on matches, plus a &lt;code&gt;deny&lt;/code&gt; rule in &lt;code&gt;.claude/settings.json&lt;/code&gt; for reading &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Tables created without RLS
&lt;/h2&gt;

&lt;p&gt;Generated migrations create the table and stop:&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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="c1"&gt;-- no "alter table ... enable row level security"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On mobile, RLS is the only security boundary, because the client key ships inside the binary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; a CI query that fails if any public table has &lt;code&gt;rowsecurity = false&lt;/code&gt;, and a deny-case test per table.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;any&lt;/code&gt; everywhere, or no types at all
&lt;/h2&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;handleSubmit&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="kr"&gt;any&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="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;Generated code reaches for &lt;code&gt;any&lt;/code&gt; the moment a type gets inconvenient, which hides exactly the bugs TypeScript exists to catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; &lt;code&gt;@typescript-eslint/no-explicit-any&lt;/code&gt; as an error, and &lt;code&gt;npx tsc --noEmit&lt;/code&gt; in the agent's definition of done.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Inline styles mixed with the styling system
&lt;/h2&gt;

&lt;p&gt;Half the components use NativeWind classes, half use &lt;code&gt;style={{ marginTop: 12 }}&lt;/code&gt;, and a few use &lt;code&gt;StyleSheet.create&lt;/code&gt;. Each one was locally reasonable; together they're unmaintainable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; &lt;code&gt;react-native/no-inline-styles&lt;/code&gt; from &lt;code&gt;eslint-plugin-react-native&lt;/code&gt;, and a single line in the agent instructions naming the one system you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Lists without stable keys and without &lt;code&gt;FlashList&lt;/code&gt;
&lt;/h2&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;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;item&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="err"&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;Index keys break reordering and deletion. A &lt;code&gt;.map&lt;/code&gt; inside a &lt;code&gt;ScrollView&lt;/code&gt; renders every item at once. Long lists need &lt;code&gt;FlatList&lt;/code&gt; at minimum, and &lt;code&gt;FlashList&lt;/code&gt; for anything that scrolls fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; &lt;code&gt;react/no-array-index-key&lt;/code&gt;, and a review rule: any list that can exceed ~20 items uses a virtualized list.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. No empty, loading, or error states
&lt;/h2&gt;

&lt;p&gt;Generated screens show the happy path. First launch with no data renders a blank screen; a failed request renders nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; a review checklist item, not a lint rule. Every screen that fetches needs three extra states. Ask the agent for them explicitly: "include loading, empty, and error states."&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Keyboard covers the input
&lt;/h2&gt;

&lt;p&gt;Forms work in the simulator and fail on a real device: the keyboard hides the submit button, and nothing scrolls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;KeyboardAvoidingView&lt;/code&gt; with &lt;code&gt;behavior="padding"&lt;/code&gt; on iOS, or &lt;code&gt;react-native-keyboard-controller&lt;/code&gt; for anything more than a login form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; test every form on a physical device with the keyboard open. No lint rule saves you here.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Navigation imported from the wrong package
&lt;/h2&gt;

&lt;p&gt;In Expo Router projects (SDK 56+), agents still import &lt;code&gt;useNavigation&lt;/code&gt; from &lt;code&gt;@react-navigation/native&lt;/code&gt;, which now creates a second navigation context and breaks routing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; &lt;code&gt;no-restricted-imports&lt;/code&gt; for &lt;code&gt;@react-navigation/*&lt;/code&gt;, and the instruction "navigation comes from &lt;code&gt;expo-router&lt;/code&gt; only."&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Hardcoded strings and magic numbers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;'Something went wrong'&lt;/code&gt; in twelve places, &lt;code&gt;#3B82F6&lt;/code&gt; in thirty, &lt;code&gt;1000&lt;/code&gt; as a timeout with no name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; a &lt;code&gt;theme.ts&lt;/code&gt; and a &lt;code&gt;strings.ts&lt;/code&gt; (or i18n setup) that exist before the agent starts, so it has somewhere to put them. Agents copy the structure they see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern behind the pattern
&lt;/h2&gt;

&lt;p&gt;Look at the fixes: most of them are &lt;strong&gt;structure that existed before the agent wrote a line&lt;/strong&gt;. A queries folder. A theme file. RLS in the migration template. Lint rules that fail the build. An instruction file that names the one way to do each thing.&lt;/p&gt;

&lt;p&gt;Agents don't write bad code because they're bad at code. They write the code the codebase implies. Give them a codebase that implies good code, and the checklist mostly stops firing.&lt;/p&gt;

&lt;p&gt;That's the thesis behind &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ai_code_review_checklist_2026&amp;amp;utm_content=footer" rel="noopener noreferrer"&gt;AppLighter&lt;/a&gt;: Expo + Supabase templates where the structure above (queries, theme, RLS migrations, lint config, agent instructions) is already in place, so Claude Code or Cursor extends a good codebase instead of inventing a mediocre one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist, copy-pasteable
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] No data fetching in &lt;code&gt;useEffect&lt;/code&gt;; queries live in hooks&lt;/li&gt;
&lt;li&gt;[ ] No secrets in &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt;; third-party keys in Edge Functions&lt;/li&gt;
&lt;li&gt;[ ] Every new table enables RLS in the same migration&lt;/li&gt;
&lt;li&gt;[ ] No &lt;code&gt;any&lt;/code&gt;; &lt;code&gt;tsc --noEmit&lt;/code&gt; passes&lt;/li&gt;
&lt;li&gt;[ ] One styling system, no inline styles&lt;/li&gt;
&lt;li&gt;[ ] Stable keys; virtualized lists for anything long&lt;/li&gt;
&lt;li&gt;[ ] Loading, empty, and error states on every fetching screen&lt;/li&gt;
&lt;li&gt;[ ] Forms tested with the keyboard open on a device&lt;/li&gt;
&lt;li&gt;[ ] Navigation from &lt;code&gt;expo-router&lt;/code&gt; only&lt;/li&gt;
&lt;li&gt;[ ] Strings and tokens in &lt;code&gt;strings.ts&lt;/code&gt; / &lt;code&gt;theme.ts&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's on your list that isn't on mine? I'd bet the eleventh item is different for every team.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ai</category>
      <category>codereview</category>
    </item>
    <item>
      <title>React Native Maps in Production: 7 Gotchas Nobody Warns You About</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:37:36 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-maps-in-production-7-gotchas-nobody-warns-you-about-2pd8</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-maps-in-production-7-gotchas-nobody-warns-you-about-2pd8</guid>
      <description>&lt;p&gt;Every tutorial makes &lt;code&gt;react-native-maps&lt;/code&gt; look easy. &lt;code&gt;npx expo install&lt;/code&gt;, drop a &lt;code&gt;&amp;lt;MapView&amp;gt;&lt;/code&gt;, done. Then you ship, and you meet the real map. The one with API keys that fail only in TestFlight, permission prompts that Apple rejects, and marker lists that turn your framerate into a slideshow.&lt;/p&gt;

&lt;p&gt;Here are the seven things that would have saved me a week of debugging on my first production map screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The blank map is almost always a height problem
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Won't render. Parent has no explicit height.&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;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MapView&lt;/span&gt; &lt;span class="na"&gt;style&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="na"&gt;flex&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="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;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Works.&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="na"&gt;style&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="na"&gt;flex&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="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;MapView&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;absoluteFill&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;View&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;&lt;code&gt;MapView&lt;/code&gt; needs an ancestor with a resolved height. &lt;code&gt;flex: 1&lt;/code&gt; inside a zero-height parent silently fails. &lt;code&gt;StyleSheet.absoluteFill&lt;/code&gt; is the safest pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;initialRegion&lt;/code&gt; vs &lt;code&gt;region&lt;/code&gt;: pick one and don't switch
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;initialRegion&lt;/code&gt; for uncontrolled maps where the user pans freely. Use &lt;code&gt;region&lt;/code&gt; only if you have a genuine reason to re-center on every render. Otherwise you're fighting user gestures and the map feels broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. API keys must be restricted per platform
&lt;/h2&gt;

&lt;p&gt;An unrestricted "any bundle ID" key works in dev and gets rotated by Google abuse detection within a week of launch. Restrict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS&lt;/strong&gt;: by bundle identifier&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android&lt;/strong&gt;: by package name + SHA-1 fingerprint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do this on day 1. Rotating a key that's already in the wild is painful.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Location permission ordering matters
&lt;/h2&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;fg&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestForegroundPermissionsAsync&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;fg&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="k"&gt;return&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;bg&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestBackgroundPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot get background permission on iOS without foreground first. And &lt;strong&gt;do not request permission on app launch&lt;/strong&gt;. Ask when the user taps a "show my location" button. Apple reviewers will reject an unexplained cold prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;Accuracy.BestForNavigation&lt;/code&gt; will kill your battery
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentPositionAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Accuracy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Balanced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 👈 default for 99% of apps&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;BestForNavigation&lt;/code&gt; is for driving and running apps that literally need meter-level precision. It's roughly 10x the battery drain of &lt;code&gt;Balanced&lt;/code&gt;. Apple reviewers do notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;watchPositionAsync&lt;/code&gt; leaks if you don't clean it up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&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;let&lt;/span&gt; &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LocationSubscription&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="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="nx"&gt;sub&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watchPositionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Accuracy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Balanced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;distanceInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loc&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;setPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&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="c1"&gt;// 👈 THIS line&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;Forget the &lt;code&gt;sub?.remove()&lt;/code&gt; and every screen visit adds a permanent GPS listener. Users notice the battery drain within a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Every &lt;code&gt;&amp;lt;Marker&amp;gt;&lt;/code&gt; is a native view
&lt;/h2&gt;

&lt;p&gt;Above roughly 200 to 300 markers on Android, frames drop. Above 2,000, iOS runs out of memory. Two fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cluster&lt;/strong&gt; with &lt;code&gt;react-native-map-clustering&lt;/code&gt; (a drop-in &lt;code&gt;MapView&lt;/code&gt; replacement built on supercluster).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bitmap markers.&lt;/strong&gt; Set &lt;code&gt;image={require('./pin.png')}&lt;/code&gt; instead of rendering React children. Native views for markers are cheap; React trees inside markers are expensive.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you must render custom React markers, wrap them in &lt;code&gt;React.memo&lt;/code&gt; and hoist all callbacks. Otherwise every pan re-renders every marker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: expo-maps vs react-native-maps in 2026
&lt;/h2&gt;

&lt;p&gt;Short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;react-native-maps&lt;/strong&gt;: huge community, works in Expo Go, needs a config plugin. The default choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expo-maps&lt;/strong&gt;: SwiftUI and Jetpack Compose under the hood, faster startup, iOS 17+ only, no Expo Go. Good for greenfield apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most teams shipping today, react-native-maps is still the safer bet, because every LLM, every Stack Overflow answer, and every AI code assistant still assumes it.&lt;/p&gt;

&lt;p&gt;Speaking of which: the entire scaffold above (maps, permissions, markers, backend) can also be generated end to end from a prompt on &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-maps-location-based-features-guide" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, if you'd rather skip the 400 lines of boilerplate and go straight to the differentiated part of your app.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Boilerplate That Actually Ships: What to Include (and Cut) in a 2026 React Native Starter</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:33:08 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/boilerplate-that-actually-ships-what-to-include-and-cut-in-a-2026-react-native-starter-2o3f</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/boilerplate-that-actually-ships-what-to-include-and-cut-in-a-2026-react-native-starter-2o3f</guid>
      <description>&lt;h2&gt;
  
  
  Why most React Native boilerplates fail past week 2
&lt;/h2&gt;

&lt;p&gt;Boilerplates optimize for the wrong number. The number that matters isn't screens included. It's time-to-first-paid-user. Every extra screen, every extra library, every unused abstraction is a tax you pay on the second refactor. The boilerplates that survive past week 2 keep the surface area small and the defaults opinionated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five must-includes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auth with session restore on cold start.&lt;/strong&gt; Sign in with Apple wired, refresh tokens handled, and a password reset that doesn't 404 in production. The scaffold that skips this rebuilds it under pressure in month one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Payments end to end.&lt;/strong&gt; Stripe or RevenueCat, with one test charge working in TestFlight. Not a screen with a Buy button. A working flow. Payments is the plumbing every indie dev underestimates.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;EAS Build + Submit wired.&lt;/strong&gt; &lt;code&gt;eas.json&lt;/code&gt; filled with staging and production profiles. GitHub Actions or manual submit, either works, but the pipeline must exist. If your first submission takes longer than an hour, that's a leading indicator of shipping never.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A real error boundary.&lt;/strong&gt; &lt;code&gt;react-native-error-boundary&lt;/code&gt; or a custom top-level catch, plus a crash-log SDK like Sentry or Bugsnag. Silent JS crashes in prod are the number one reason indie apps ship a broken v1.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OTA update channels.&lt;/strong&gt; &lt;code&gt;expo-updates&lt;/code&gt; with channel-level control. Push a JS bundle to a beta channel, promote to production without touching the app-store binary. This is where "faster to build" becomes "faster to fix".&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The five to cut by default
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The navigation grab-bag.&lt;/strong&gt; Pick one (expo-router with typed hrefs) and delete the others. Multiple navigators in one boilerplate is a smell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animation libraries "just in case".&lt;/strong&gt; Don't ship reanimated + moti + lottie + a bespoke animation hook. Include one and document why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-house UI kits.&lt;/strong&gt; Every custom Button component is a maintenance burden. Use platform primitives or a maintained design-system library (Tamagui, Restyle), not a hand-rolled kit that will diverge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redux for a v1 app.&lt;/strong&gt; Zustand or Jotai handles it. Redux plus Redux Toolkit plus RTK Query is 300 lines of setup before your first fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "example" screens.&lt;/strong&gt; Delete the calendar demo, the chat demo, the map demo. They confuse new devs and drift into stale APIs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rename-screen test
&lt;/h2&gt;

&lt;p&gt;Here's a one-minute audit for any starter you're evaluating. Rename &lt;code&gt;screens/Home.tsx&lt;/code&gt; to &lt;code&gt;screens/Dashboard.tsx&lt;/code&gt;. Everything that survives without manual patching (router, deep links, typegen) is a real boilerplate. Anything that needs a find-and-replace is a demo dressed up as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pragmatic 2026 stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework:&lt;/strong&gt; Expo SDK 53+, expo-router with typed hrefs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State:&lt;/strong&gt; Zustand for client, TanStack Query for server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth + DB:&lt;/strong&gt; Supabase (or &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rn-boilerplate" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt; for local-first and self-host)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments:&lt;/strong&gt; Stripe SDK + Payment Sheet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Style:&lt;/strong&gt; Restyle or unistyles, design tokens on day one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI:&lt;/strong&gt; GitHub Actions + EAS Build with staging and prod profiles&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The folder-deletion principle
&lt;/h2&gt;

&lt;p&gt;The best RN scaffold is one you can delete a folder from. Every project ships a subset, and your job as a maintainer is to make the deletion trivial. We built &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rn-boilerplate" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; to generate React Native + Expo apps that start from these defaults. Grab it, or clone your own. The principles matter more than the repo.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Delayed Push Permissions in React Native: Why the Third-Session Prompt Wins</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 16 Sep 2026 04:58:11 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/delayed-push-permissions-in-react-native-why-the-third-session-prompt-wins-2fc0</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/delayed-push-permissions-in-react-native-why-the-third-session-prompt-wins-2fc0</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Prompt-on-install caps push acceptance at ~45%; a third-session gate lifts it to ~78%&lt;/li&gt;
&lt;li&gt;Session count is a proxy: tie the prompt to a habit moment when you have one&lt;/li&gt;
&lt;li&gt;Skip the iOS pre-permission modal (net -6pp in practice)&lt;/li&gt;
&lt;li&gt;Already shipped? &lt;code&gt;Linking.openSettings()&lt;/code&gt; is your only recovery path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most React Native tutorials on push notifications skip the single decision that matters most: when to ask.&lt;/p&gt;

&lt;p&gt;The default in every boilerplate is to prompt on install day, usually in the onboarding flow. That decision quietly caps your addressable notification audience at whatever fraction of users don't tap "Don't Allow" during the least trusting three seconds of their relationship with your app. Across the RapidNative-built apps we can see, that fraction is about 45%.&lt;/p&gt;

&lt;p&gt;Move the prompt to after the third session — or better, tie it to a habit-forming moment — and acceptance climbs to about 78%. The change is a 3-line delta in your app entry file. This post walks through the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "the third session" actually means
&lt;/h2&gt;

&lt;p&gt;You don't need a fancy engagement model. Track sessions in AsyncStorage:&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="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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&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-native-async-storage/async-storage&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;SESSION_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;session_count&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;PROMPTED_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;push_prompted&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;maybeAskForPushPermission&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;already&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;PROMPTED_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;already&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawCount&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;SESSION_KEY&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;count&lt;/span&gt; &lt;span class="o"&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;rawCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&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="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;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;SESSION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&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="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;requestPermissionsAsync&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;PROMPTED_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&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;status&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;Call &lt;code&gt;maybeAskForPushPermission()&lt;/code&gt; from your root layout's &lt;code&gt;useEffect&lt;/code&gt;, not from onboarding. The user sees the prompt on their third open, in a moment where they've already decided your app is worth returning to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the habit-moment version is better
&lt;/h2&gt;

&lt;p&gt;Session count is a proxy. The signal you actually want is "user just completed a meaningful action." Tie the prompt to the completion event instead:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onFirstJournalEntrySaved&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;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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;has_first_entry&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;true&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;maybeAskForPushPermission&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 the permission prompt lands in the exact second the user is thinking "I could see myself using this again." Different question in the user's head. Different answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The iOS-specific trap: pre-permission modal
&lt;/h2&gt;

&lt;p&gt;The pattern iOS teams reach for — showing your own custom "Would you like push?" modal before triggering the real prompt — has a subtle failure mode. If the user taps "Yes" on your modal and "Don't Allow" on the real one, you cannot re-prompt. iOS is one-and-done.&lt;/p&gt;

&lt;p&gt;Skip the pre-modal unless you have data that your specific audience needs it. In our tests it dropped acceptance by 6 percentage points net, because "yes to modal + no to real prompt" was a bigger cohort than "no to modal at all."&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do if you already shipped with prompt-on-install
&lt;/h2&gt;

&lt;p&gt;You cannot re-prompt users who already denied. But you can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the delayed prompt for new installs.&lt;/li&gt;
&lt;li&gt;Build a settings page that deep-links to the iOS Settings &amp;gt; Notifications page for your app, so denied users can re-enable manually. &lt;code&gt;Linking.openSettings()&lt;/code&gt; handles this.&lt;/li&gt;
&lt;li&gt;Show the deep-link once, in-app, at a habit moment. Don't nag.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The whole thing in ~20 lines
&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;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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&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-native-async-storage/async-storage&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;Linking&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-native&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;SESSION_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;session_count&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;PROMPTED_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;push_prompted&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;maybeAskForPushPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&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;if &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;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PROMPTED_KEY&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&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;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_KEY&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;0&lt;/span&gt;&lt;span class="dl"&gt;'&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="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;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;SESSION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;force&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="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;requestPermissionsAsync&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;PROMPTED_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&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;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Recovery flow for users who already denied&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;openNotificationSettings&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;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;await&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openSettings&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 is the pattern. Session-count gate, tie to a habit moment if you can (pass &lt;code&gt;force = true&lt;/code&gt; from the completion event), use &lt;code&gt;Linking.openSettings()&lt;/code&gt; for the recovery flow.&lt;/p&gt;

&lt;p&gt;Something like &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=delayed-push-permissions-third-session" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; ships this gate in its generated Expo projects by default, so if you're scaffolding a new app the prompt is already off line 3 before you've written anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't ship the 45% cap
&lt;/h2&gt;

&lt;p&gt;The point of writing this up is not that it's complex. It's that this is the highest-leverage thing you can do to your push notification stack this quarter, and most React Native tutorials still show you &lt;code&gt;requestPermissionsAsync()&lt;/code&gt; on line 3 of the app.&lt;/p&gt;

&lt;p&gt;If you've measured acceptance before and after moving the prompt, drop a comment with your numbers. Curious whether the third-session threshold holds across categories or whether it's a journaling/fitness thing.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>notifications</category>
      <category>ux</category>
    </item>
    <item>
      <title>How to Build an AI-Powered Personal Finance App with React Native and Expo</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Fri, 04 Sep 2026 13:07:27 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-build-an-ai-powered-personal-finance-app-with-react-native-and-expo-4554</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-build-an-ai-powered-personal-finance-app-with-react-native-and-expo-4554</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Four Postgres tables + RLS cover 90% of a personal finance MVP&lt;/li&gt;
&lt;li&gt;Rules-based categorization first, LLM fallback second (the LLM's job is bootstrapping the rules)&lt;/li&gt;
&lt;li&gt;Skip Plaid at v1: manual entry, done well, is a real product&lt;/li&gt;
&lt;li&gt;Prompt-scaffold the v1, spend your time on categorization heuristics and first-run UX&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Personal finance is one of those categories where the market keeps growing but the incumbents keep frustrating users. Mint shut down, YNAB raised prices, and Rocket Money spent its way to a huge user base. If you're a React Native dev thinking about building in this space, 2026 is a great time. AI-native tooling collapses the scaffolding phase enough that a solo dev can ship a real v1 in a week.&lt;/p&gt;

&lt;p&gt;Here's how I'd approach it end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core data model (Postgres via Supabase)
&lt;/h2&gt;

&lt;p&gt;Four tables cover 90% of what a personal finance app does:&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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'checking'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'credit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'cash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'savings'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'USD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;14&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="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;icon&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;monthly_budget&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;14&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="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;category_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;14&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="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;merchant&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;occurred_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;occurred_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable RLS on every table with &lt;code&gt;user_id = auth.uid()&lt;/code&gt; policies. This is not optional in a finance app. It's the difference between "shipped an MVP" and "leaked a stranger's bank data."&lt;/p&gt;

&lt;h2&gt;
  
  
  The screen tree
&lt;/h2&gt;

&lt;p&gt;Expo Router, tabs at the root:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;(tabs)/index.tsx&lt;/code&gt;: home with month spend, budget remaining, category donut, recent transactions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(tabs)/transactions.tsx&lt;/code&gt;: list, grouped by day, with search + filter&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(tabs)/budgets.tsx&lt;/code&gt;: card per category&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(tabs)/insights.tsx&lt;/code&gt;: charts (I use &lt;code&gt;victory-native&lt;/code&gt; for these)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(tabs)/settings.tsx&lt;/code&gt;: biometric toggle, currency, CSV export, dark mode&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;add-transaction.tsx&lt;/code&gt;: big number pad, merchant autocomplete, category picker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add-transaction is the one screen users touch daily. Optimize the hell out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI categorization loop
&lt;/h2&gt;

&lt;p&gt;Two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rules table.&lt;/strong&gt; Regex on merchant string -&amp;gt; category_id. Covers 70–80% of what you see in practice ("STARBUCKS.*" -&amp;gt; Coffee).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM fallback.&lt;/strong&gt; For unmatched merchants, prompt a model with the merchant + the user's category list, ask for &lt;code&gt;{ category_id, confidence }&lt;/code&gt;. Store the answer so it becomes a rule. Cache aggressively: the same 100 merchants generate 80% of a user's transactions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do the rules first, add the LLM later. It's the correct order because the LLM's whole job is to bootstrap the rules table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bank connections: later, not now
&lt;/h2&gt;

&lt;p&gt;Every finance-app tutorial jumps straight to Plaid. Don't. Plaid charges per connected user per month once you're out of the free tier, and it comes with a real support burden ("why does my bank keep disconnecting?"). Ship a well-designed manual tracker first. Add Plaid in v2 as a Pro feature once you have signal that users actually want the automation.&lt;/p&gt;

&lt;p&gt;If and when you do connect, use their &lt;a href="https://plaid.com/docs/link/" rel="noopener noreferrer"&gt;Link SDK&lt;/a&gt;. Never accept bank credentials in your own UI, ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI-native way to scaffold all of this
&lt;/h2&gt;

&lt;p&gt;I've built React Native apps by hand and by prompting, and there's no serious argument for hand-scaffolding a v1 anymore. Prompt the whole thing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A personal finance app with tabs: home, transactions, budgets, insights, settings. Home shows monthly spend, remaining budget, category donut chart. Add-transaction has a big number pad, merchant autocomplete, category picker, account selector. Use Supabase for auth + data. Enable RLS. Dark palette, subtle green for income, subtle red for expenses.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Something like &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=how-to-build-ai-powered-personal-finance-app" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; will spit out a real Expo project: real navigation, real Supabase client, generated types matched to the schema. You spend your time on the actually-hard parts (categorization heuristics, chart clarity, first-run UX) instead of typing &lt;code&gt;useNavigation&lt;/code&gt; for the eightieth time in your life.&lt;/p&gt;

&lt;p&gt;Whatever tool you pick, verify it exports plain code. You want to own the project, not rent it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security checklist before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Biometric unlock (Expo LocalAuthentication), required on cold start after N minutes.&lt;/li&gt;
&lt;li&gt;No sensitive fields in Sentry / analytics payloads.&lt;/li&gt;
&lt;li&gt;RLS on every table.&lt;/li&gt;
&lt;li&gt;HTTPS only (default in Expo, but audit anyway).&lt;/li&gt;
&lt;li&gt;Data export + delete-my-account (GDPR/CCPA, also good UX).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd skip on day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Investment tracking (whole different data shape).&lt;/li&gt;
&lt;li&gt;Bill negotiation / subscription cancellation (Rocket Money moat; expensive to build).&lt;/li&gt;
&lt;li&gt;Real bank connections (Plaid).&lt;/li&gt;
&lt;li&gt;Household / joint budgeting (v2 feature).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ship view-only + manual + smart categorization. That's a real product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the prompt path first
&lt;/h2&gt;

&lt;p&gt;Even if you're a seasoned dev, prompt the first version before you write a line. Worst case you throw it out. Best case you shave weeks off your MVP. The free tier is enough to see whether the output is close to what you'd build.&lt;/p&gt;

&lt;p&gt;Ship soon. The category is wide open. If you're building in fintech-adjacent RN right now, drop a comment with what you're working on. Curious what the categorization edge cases look like in other people's data.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ai</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your app/ folder is your information architecture</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:37:48 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/your-app-folder-is-your-information-architecture-12nc</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/your-app-folder-is-your-information-architecture-12nc</guid>
      <description>&lt;p&gt;Information architecture is the deliverable everyone nods along to and nobody enforces. You draw the sitemap in Figma. A developer builds the navigator tree in code. Six weeks later the two have quietly drifted, and onboarding someone new takes a thirty-minute explanation of why Settings lives inside the Account stack when the design has it as a top-level tab.&lt;/p&gt;

&lt;p&gt;Expo Router's file-based routing removes that gap. Not by adding process, but by making the folder structure the thing both sides read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The folder is the sitemap
&lt;/h2&gt;

&lt;p&gt;Here is the shape of a real app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
├── _layout.tsx
├── (tabs)/
│   ├── _layout.tsx
│   ├── index.tsx           # Home
│   ├── explore.tsx         # Explore
│   └── profile/
│       ├── _layout.tsx
│       ├── index.tsx
│       ├── edit.tsx
│       └── settings.tsx
├── (auth)/
│   ├── _layout.tsx
│   ├── login.tsx
│   └── signup.tsx
└── modal.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open that in any file explorer and the app's shape is visible without reading a line of TypeScript. Three tabs. A nested profile section. An auth flow in its own gated group. A modal at the top level, because it can be presented from anywhere.&lt;/p&gt;

&lt;p&gt;Now compare it to &lt;code&gt;RootNavigator.tsx&lt;/code&gt; → &lt;code&gt;TabNavigator.tsx&lt;/code&gt; → &lt;code&gt;ProfileStack.tsx&lt;/code&gt;. The same information exists, spread across three files, and none of them show the shape at a glance. A designer can't open those files and learn anything. They can open the folder tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving a screen is moving a file
&lt;/h2&gt;

&lt;p&gt;Say you want to promote Settings out of Profile and up to a top-level tab.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv &lt;/span&gt;app/&lt;span class="se"&gt;\(&lt;/span&gt;tabs&lt;span class="se"&gt;\)&lt;/span&gt;/profile/settings.tsx app/&lt;span class="se"&gt;\(&lt;/span&gt;tabs&lt;span class="se"&gt;\)&lt;/span&gt;/settings.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the change. No navigator config to update, no linking config to rewrite, no &lt;code&gt;screenOptions&lt;/code&gt; to migrate. The route is now &lt;code&gt;/settings&lt;/code&gt; instead of &lt;code&gt;/profile/settings&lt;/code&gt;, and with typed routes turned on, every &lt;code&gt;router.push&lt;/code&gt; that pointed at the old path either autocompletes to the new one or gets flagged by TypeScript before it ships.&lt;/p&gt;

&lt;p&gt;The design intent — &lt;em&gt;this should be top-level&lt;/em&gt; — becomes one commit. That matters more than it sounds. When a structural change costs an afternoon of navigator surgery, it doesn't get made. It gets deferred, and the sitemap becomes aspirational.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layouts are where design lives
&lt;/h2&gt;

&lt;p&gt;Each &lt;code&gt;_layout.tsx&lt;/code&gt; defines the header, the tab bar, the presentation style, and the safe-area handling for everything beneath it. That makes it the single file that answers the questions design review actually asks: what does someone see when they enter this section? What's the back affordance? What's the visual container?&lt;/p&gt;

&lt;p&gt;In a navigator-based app, those answers are scattered across &lt;code&gt;screenOptions&lt;/code&gt;, tab bar props, and &lt;code&gt;headerLeft&lt;/code&gt; overrides on individual screens. Reviewing them as a designer means asking a developer to narrate the code. With &lt;code&gt;_layout.tsx&lt;/code&gt;, it's one file per section, and you can read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A five-minute IA review
&lt;/h2&gt;

&lt;p&gt;We run this weekly. Design and engineering open the &lt;code&gt;app/&lt;/code&gt; folder together and ask three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the folder shape still match the sitemap?&lt;/li&gt;
&lt;li&gt;Are there orphaned routes — files with no entry point in the UI?&lt;/li&gt;
&lt;li&gt;Has any &lt;code&gt;_layout.tsx&lt;/code&gt; drifted from its section-header spec?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It used to take the better part of an hour on a navigator-based project, mostly spent reconstructing the tree from three files before anyone could evaluate it. Now the reconstruction step is free.&lt;/p&gt;

&lt;p&gt;What we catch is unglamorous and worth catching: the profile stack that grew a fifth screen nobody remembers adding, the modal that got promoted to a full screen but kept its &lt;code&gt;presentation: 'modal'&lt;/code&gt; prop, the route that survived a feature cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that isn't automatic
&lt;/h2&gt;

&lt;p&gt;File-based routing makes drift &lt;em&gt;visible&lt;/em&gt;. It doesn't stop it. Groups like &lt;code&gt;(tabs)&lt;/code&gt; and &lt;code&gt;(auth)&lt;/code&gt; don't appear in the URL, so the folder tree and the navigation model can still diverge if you nest groups aggressively. And a folder structure that mirrors your sitemap perfectly can still describe a bad sitemap.&lt;/p&gt;

&lt;p&gt;What changes is the cost of noticing. When the IA is a file tree, anyone on the team can audit it in a minute — and the thing that stops IA reviews from happening is almost never disagreement. It's that nobody can see the current state clearly enough to have the argument.&lt;/p&gt;

&lt;p&gt;If you're leading design on a React Native app and you've never opened the &lt;code&gt;app/&lt;/code&gt; folder, open it. It's the most honest artifact you have.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Suraj from the RapidNative Team. We build &lt;a href="https://rapidnative.com" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, which generates real React Native and Expo projects from a prompt — file-based routing included.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ux</category>
      <category>design</category>
    </item>
    <item>
      <title>Real-Time Location Tracking in React Native, Including Live Sharing</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Fri, 28 Aug 2026 11:17:07 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/real-time-location-tracking-in-react-native-including-live-sharing-848</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/real-time-location-tracking-in-react-native-including-live-sharing-848</guid>
      <description>&lt;p&gt;The moving dot on a delivery app looks trivial. Underneath it: a permission sequence that fails silently if you get the order wrong, a background task the OS actively wants to kill, a battery budget you have to defend, and a connection that has to survive a phone going into a pocket.&lt;/p&gt;

&lt;p&gt;Most tutorials stop at &lt;code&gt;watchPositionAsync&lt;/code&gt; on a demo screen. This goes through to live sharing between two devices.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request foreground permission first, background second, and never on launch&lt;/li&gt;
&lt;li&gt;Define the background task at module top level, not inside a component&lt;/li&gt;
&lt;li&gt;Android needs a foreground service or the OS kills the task within minutes&lt;/li&gt;
&lt;li&gt;Use broadcast for live position, not database inserts&lt;/li&gt;
&lt;li&gt;Coarsest accuracy and longest interval that still feels responsive&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-location react-native-maps expo-task-manager
npm &lt;span class="nb"&gt;install&lt;/span&gt; @supabase/supabase-js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;expo-location&lt;/code&gt; handles foreground and background GPS plus permissions. &lt;code&gt;react-native-maps&lt;/code&gt; renders Apple Maps on iOS and Google Maps on Android. &lt;code&gt;expo-task-manager&lt;/code&gt; is required for background location. Supabase provides the realtime channel for the sharing half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Permissions
&lt;/h2&gt;

&lt;p&gt;This is where most of the bugs live.&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&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;"plugins"&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="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"expo-location"&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;"locationAlwaysAndWhenInUsePermission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow $(PRODUCT_NAME) to share your live location."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"isIosBackgroundLocationEnabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"isAndroidBackgroundLocationEnabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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="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;"ios"&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;"infoPlist"&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;"UIBackgroundModes"&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="s2"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fetch"&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="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="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;Request foreground first, background second. Always that order.&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="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;Location&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-location&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;requestLocationPermissions&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fg&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestForegroundPermissionsAsync&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;fg&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Location required&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="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;bg&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestBackgroundPermissionsAsync&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;bg&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Background disabled&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;And do not request background on app launch. Wait until the user taps something that needs it, like "start sharing". Asking for always-on location from a stranger is how you get a permanent denial on the first screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Foreground tracking
&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="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;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;Location&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-location&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;useLiveLocation&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;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLocation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LocationObject&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&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;let&lt;/span&gt; &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LocationSubscription&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;null&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="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;sub&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watchPositionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Accuracy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BestForNavigation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;timeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;distanceInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nx"&gt;setLocation&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;location&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;Three knobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;accuracy&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;Balanced&lt;/code&gt; for delivery ETAs, &lt;code&gt;BestForNavigation&lt;/code&gt; for anything where a few metres matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;timeInterval&lt;/code&gt;.&lt;/strong&gt; Minimum milliseconds between fixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;distanceInterval&lt;/code&gt;.&lt;/strong&gt; Minimum metres moved before firing. Set this to 5 or 10, otherwise a stationary user on a poor fix floods your UI with jitter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the difference between a dot that sits still and a dot that vibrates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The map
&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="nx"&gt;MapView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Marker&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-native-maps&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;TrackingScreen&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;location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useLiveLocation&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;location&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;latitudeDelta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;longitudeDelta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.005&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MapView&lt;/span&gt; &lt;span class="na"&gt;style&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="na"&gt;flex&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="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;showsUserLocation&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;Marker&lt;/span&gt; &lt;span class="na"&gt;coordinate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"You are here"&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;MapView&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;region&lt;/code&gt;, not &lt;code&gt;initialRegion&lt;/code&gt;, if you want the map to follow. For a marker that slides between fixes rather than teleporting, animate the coordinate rather than setting it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Background tracking
&lt;/h2&gt;

&lt;p&gt;Define the task at &lt;strong&gt;module top level&lt;/strong&gt;. Not inside a component, not inside a hook. The OS invokes it before your React tree exists.&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="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;TaskManager&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-task-manager&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Location&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-location&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;LOCATION_TASK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;background-location-task&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;TaskManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCATION_TASK&lt;/span&gt;&lt;span class="p"&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="nx"&gt;error&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;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;locations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;locations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LocationObject&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendLocationsToServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locations&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startBackgroundTracking&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startLocationUpdatesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCATION_TASK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Accuracy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Balanced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;distanceInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;foregroundService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;notificationTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sharing your location&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;notificationBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tap to open the app.&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;pausesUpdatesAutomatically&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;activityType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ActivityType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Fitness&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;Two non-negotiables. On Android, &lt;code&gt;foregroundService&lt;/code&gt; or the OS kills the task within minutes. On iOS, &lt;code&gt;UIBackgroundModes: ["location"]&lt;/code&gt; or you get rejected at review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Live sharing
&lt;/h2&gt;

&lt;p&gt;The part most tutorials skip.&lt;/p&gt;

&lt;p&gt;A Supabase Realtime channel must be subscribed before it will send anything. Creating a channel and immediately calling &lt;code&gt;send&lt;/code&gt; transmits nothing, and it fails quietly, which makes it a genuinely unpleasant afternoon.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&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;@supabase/supabase-js&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;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// create and subscribe once, then reuse&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;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&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="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`session:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&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;channel&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;channel&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;function&lt;/span&gt; &lt;span class="nf"&gt;shareLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LocationObject&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;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&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="s1"&gt;broadcast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;position&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;On the observing device:&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="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;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`session:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broadcast&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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;position&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;payload&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;setPeerLocation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;latitude&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="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;longitude&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="nx"&gt;lng&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;subscribe&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="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsubscribe&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;sessionId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Broadcast, not database inserts.&lt;/strong&gt; Inserting every fix burns through row quota and forces observers to poll. If you also need history for a breadcrumb trail or route replay, write to a table on a much slower cadence in addition to the broadcast. Two different jobs, two different mechanisms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Battery
&lt;/h2&gt;

&lt;p&gt;Continuous high-accuracy GPS is one of the most expensive things an app can do, and the cost scales with accuracy and frequency in roughly the way you'd expect.&lt;/p&gt;

&lt;p&gt;The ordering that matters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Relative cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BestForNavigation&lt;/code&gt;, 1 to 2s, foreground&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Balanced&lt;/code&gt;, 10s, background&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Lowest&lt;/code&gt;, 30s, background&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Absolute numbers depend heavily on device, OS version, and whether the screen is on, so measure on your own target hardware rather than trusting anyone's table, including this one.&lt;/p&gt;

&lt;p&gt;The rule that generalises: use the coarsest accuracy and longest interval that still feels responsive, and set &lt;code&gt;pausesUpdatesAutomatically: true&lt;/code&gt;, which lets iOS suspend tracking when the user has stopped moving. That last flag is close to free and it does more than most tuning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blank map on Android.&lt;/strong&gt; Missing &lt;code&gt;android.config.googleMaps.apiKey&lt;/code&gt; in app config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;watchPositionAsync&lt;/code&gt; never fires in the simulator.&lt;/strong&gt; The iOS simulator has no GPS. Set a custom location from the simulator's location menu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission dialog on every launch.&lt;/strong&gt; You're calling &lt;code&gt;request*&lt;/code&gt; where you should call &lt;code&gt;get*&lt;/code&gt; first and only request when the answer is undetermined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background works locally, dies in production.&lt;/strong&gt; Missing &lt;code&gt;UIBackgroundModes&lt;/code&gt; on iOS or &lt;code&gt;foregroundService&lt;/code&gt; on Android. Both work fine in development, which is what makes this one expensive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Skipping the plumbing
&lt;/h2&gt;

&lt;p&gt;If you want the scaffold without the permission archaeology, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=real-time-location-tracking" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates the Expo and Supabase project from a description, and the output is real React Native you can export and finish wherever you normally work.&lt;/p&gt;

&lt;p&gt;The parts above still need understanding. Generated or not, the OS is going to kill your background task if the foreground service isn't configured, and no scaffold argues with that.&lt;/p&gt;




&lt;p&gt;What's your background tracking horror story? Mine was a task that worked for three weeks and then stopped, because the notification the foreground service depends on had been silently disabled by the user.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>tutorial</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Reanimated vs Moti vs Skia: Which React Native Animation Library Should You Actually Use?</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 24 Aug 2026 11:52:58 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/reanimated-vs-moti-vs-skia-which-react-native-animation-library-should-you-actually-use-2j7m</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/reanimated-vs-moti-vs-skia-which-react-native-animation-library-should-you-actually-use-2j7m</guid>
      <description>&lt;p&gt;Every React Native team eventually asks the same question: &lt;strong&gt;Reanimated, Moti, or Skia?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The framing is wrong. These three don't compete, they compose. Here's the mental model that will save you a bad refactor.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reanimated&lt;/strong&gt; is the runtime. The other two depend on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moti&lt;/strong&gt; is a declarative wrapper over Reanimated. Less code, less control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skia&lt;/strong&gt; is a rendering engine, not an animation library. It accepts Reanimated shared values directly.&lt;/li&gt;
&lt;li&gt;Install Reanimated and Moti on day one. Add Skia when the design demands it.&lt;/li&gt;
&lt;li&gt;Pick per interaction, not per app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one-sentence version of each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reanimated.&lt;/strong&gt; The animation runtime. Runs worklets on the UI thread through JSI. Everything else you install depends on it directly or indirectly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moti.&lt;/strong&gt; A Framer Motion-style declarative wrapper &lt;em&gt;over&lt;/em&gt; Reanimated. Less code for common transitions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skia.&lt;/strong&gt; Google's 2D graphics engine, the one behind Chrome and Flutter, exposed as React Native components. Not an animation library. A rendering engine that happens to animate beautifully when you feed it shared values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reanimated in about ten lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Animated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useSharedValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useAnimatedStyle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;withSpring&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-native-reanimated&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSharedValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAnimatedStyle&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;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;translateX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&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="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// somewhere in a handler:&lt;/span&gt;
&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withSpring&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Animated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;style&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="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;style&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;&lt;code&gt;x&lt;/code&gt; is a &lt;strong&gt;shared value&lt;/strong&gt;, and it lives on the UI thread. &lt;code&gt;useAnimatedStyle&lt;/code&gt; is a &lt;strong&gt;worklet&lt;/strong&gt; that reruns when &lt;code&gt;x&lt;/code&gt; changes. A blocked JS thread doesn't stutter the animation, which is the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing in Moti
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MotiView&lt;/span&gt;
  &lt;span class="na"&gt;from&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="na"&gt;translateX&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="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;animate&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="na"&gt;translateX&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="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;transition&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="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="s1"&gt;spring&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same worklets underneath. Half the code.&lt;/p&gt;

&lt;p&gt;That's the trade: you lose direct access to the shared value, which matters the moment you want to drive it from a gesture. For a mount transition you'll never miss it. For a drag you'll rewrite it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each one wins
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A fade-in on mount&lt;/td&gt;
&lt;td&gt;Moti&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A pan gesture moving a card&lt;/td&gt;
&lt;td&gt;Reanimated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A skeleton loader&lt;/td&gt;
&lt;td&gt;Moti&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinch-to-zoom on an image&lt;/td&gt;
&lt;td&gt;Reanimated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An animated chart with 60+ points&lt;/td&gt;
&lt;td&gt;Skia + Reanimated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A shader-driven splash screen&lt;/td&gt;
&lt;td&gt;Skia&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A stagger animation on a list&lt;/td&gt;
&lt;td&gt;Moti&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A blur that follows a bottom sheet&lt;/td&gt;
&lt;td&gt;Skia + Reanimated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The composition detail people miss
&lt;/h2&gt;

&lt;p&gt;Skia components accept Reanimated shared values as props &lt;strong&gt;directly&lt;/strong&gt;. No &lt;code&gt;createAnimatedComponent&lt;/code&gt;, no &lt;code&gt;useAnimatedProps&lt;/code&gt; wrapper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSharedValue&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="c1"&gt;// progress updates, Skia canvas repaints, all on the UI thread&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt; &lt;span class="na"&gt;style&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="na"&gt;flex&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="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;Circle&lt;/span&gt; &lt;span class="na"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;r&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;40&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="s"&gt;"hotpink"&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;Canvas&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 single fact is what made Victory Native XL and every other modern chart library possible. Before it, animating a canvas meant round-tripping through JS every frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The performance gotcha
&lt;/h2&gt;

&lt;p&gt;You will not hit a performance ceiling because you picked the "wrong" library. All three drive animations on the UI thread.&lt;/p&gt;

&lt;p&gt;You will hit one if you animate a property that forces a layout pass every frame. Reanimated can push a shared value at 120fps, but if consuming it triggers layout, you're queued behind that layout and you drop frames regardless of which library issued the update.&lt;/p&gt;

&lt;p&gt;Animate &lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt;. Animating &lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, &lt;code&gt;top&lt;/code&gt;, or &lt;code&gt;left&lt;/code&gt; is the version of this mistake people actually ship, and all three libraries will let you do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I use
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pick per interaction, not per app.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A screen with a draggable card and a hero image wants Reanimated for the drag and Moti for the hero. Separate primitives, separate concerns.&lt;/p&gt;

&lt;p&gt;The moment you try to standardise on one library, you make one of those two interactions worse. Usually the drag, because that's the one where the abstraction costs you the handle you needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Install Reanimated and Moti on day one. Add Skia when the design demands it. Compose freely.&lt;/p&gt;

&lt;p&gt;The interesting part of this generalises past animation: the "pick per interaction" rule is roughly how &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-animation-libraries" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; decides which primitive to emit for a given screen, since a prompt describing a drag and a prompt describing a fade want different code.&lt;/p&gt;




&lt;p&gt;What's your default? I'm curious how many people reach for Reanimated directly on things Moti would handle in three lines, because I did that for about a year.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>javascript</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>We Made Checkout 600ms Faster. Not One User Noticed.</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 19 Aug 2026 06:41:56 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/we-made-checkout-600ms-faster-not-one-user-noticed-2ich</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/we-made-checkout-600ms-faster-not-one-user-noticed-2ich</guid>
      <description>&lt;p&gt;We spent a sprint on checkout performance. Prefetched tokens, warmed the payment sheet, bundle-split the checkout screen. End-to-end timing improved by roughly 600ms, which is a real number and visible in the traces.&lt;/p&gt;

&lt;p&gt;Then we retested with users. Not one person mentioned speed. Not in the sessions where it was faster, and not in the earlier ones where it wasn't.&lt;/p&gt;

&lt;p&gt;Everything they &lt;em&gt;did&lt;/em&gt; mention fell into one category, and it wasn't performance.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Measurable speed improvements produced zero user comments in moderated retests&lt;/li&gt;
&lt;li&gt;Every drop-off users could articulate was a trust problem, not a latency problem&lt;/li&gt;
&lt;li&gt;Biometric confirmation changed how users described an otherwise identical flow&lt;/li&gt;
&lt;li&gt;Stripe's default decline message reliably reads as "your card is fraud-flagged"&lt;/li&gt;
&lt;li&gt;We now review payment flows against four trust questions before touching perf&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a follow-up. The earlier findings from this testing (the double-tap problem and the total-mismatch problem) are in &lt;a href="https://dev.to/"&gt;the first writeup&lt;/a&gt;; this post covers what surfaced afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Users drop off at trust moments, not slow moments
&lt;/h2&gt;

&lt;p&gt;That's the headline and it took us a sprint of wasted effort to learn.&lt;/p&gt;

&lt;p&gt;When someone abandons a checkout, they can usually tell you why, and the reason is almost always about confidence rather than time. Nobody says "that took 900 milliseconds and I was unwilling to wait." They say some version of "I wasn't sure what was happening" or "that didn't feel right."&lt;/p&gt;

&lt;p&gt;Latency matters when it &lt;em&gt;creates&lt;/em&gt; a trust problem. A tap that produces no visible response for half a second reads as a broken button, and the user taps again. That's a trust failure caused by latency, and fixing the latency fixes it. But shaving 600ms off a flow that already felt responsive changed nothing anyone could perceive.&lt;/p&gt;

&lt;p&gt;If you have a fixed budget for a payment flow rebuild, spend it on the four things below before you open a profiler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Biometrics change how the same flow feels
&lt;/h2&gt;

&lt;p&gt;This one surprised us most.&lt;/p&gt;

&lt;p&gt;Users describing the flow &lt;em&gt;with&lt;/em&gt; a Face ID confirmation on the final tap used the word "safe." The same users describing the flow &lt;em&gt;without&lt;/em&gt; it reached for "kind of sketchy," "is this the real app," and "I don't know if it went through."&lt;/p&gt;

&lt;p&gt;Same gateway. Same UI. Same latency. The only delta was a native biometric prompt before presenting the sheet.&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="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;LocalAuthentication&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-local-authentication&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;confirmAndPay&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;hasHardware&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;LocalAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasHardwareAsync&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;isEnrolled&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;LocalAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEnrolledAsync&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;hasHardware&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;isEnrolled&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;result&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;LocalAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticateAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;promptMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Confirm your purchase&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fallbackLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Use passcode&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;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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;presentPaymentSheet&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;Note the graceful degradation. Devices without enrolled biometrics fall through to the sheet rather than blocking the purchase, which matters more than it sounds — gating checkout behind hardware some users don't have is a worse problem than the one you're solving.&lt;/p&gt;

&lt;p&gt;The interesting part is that nobody asked for this. No user in any session said "I wish this had Face ID." They just described the version without it as untrustworthy, in language they'd never have produced in response to a survey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe's default decline copy is actively harmful
&lt;/h2&gt;

&lt;p&gt;"Your card was declined."&lt;/p&gt;

&lt;p&gt;Technically accurate. In sessions, users read it as &lt;em&gt;your card has been flagged&lt;/em&gt;, assumed something was wrong with their account rather than with this transaction, and several closed the app entirely rather than trying another card.&lt;/p&gt;

&lt;p&gt;The fix is mapping decline codes to plain language plus a recovery action:&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;DECLINE_COPY&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&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;action&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;insufficient_funds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your bank says there are insufficient funds on this card.&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Try a different card&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;incorrect_cvc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The security code didn't match.&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Re-enter card details&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;expired_card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This card has expired.&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Use a different card&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;generic_decline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your bank declined this charge. This is usually temporary.&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Try a different card&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;Two details that matter more than the copy itself. Attribute the decline to the &lt;em&gt;bank&lt;/em&gt;, not to your app, because users who think your app rejected them do not retry. And make the recovery action reopen the sheet with the cart intact, rather than dumping them back at the top of checkout, which is a second abandonment opportunity you built yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Show the real total before the sheet opens
&lt;/h2&gt;

&lt;p&gt;Related to but distinct from the total-mismatch problem in the first writeup: it isn't only that the numbers must match, it's &lt;em&gt;when&lt;/em&gt; the user learns the final number.&lt;/p&gt;

&lt;p&gt;Fees and taxes computed server-side and revealed inside the payment sheet are technically visible, but by then the user has anchored on the cart price. A higher number appearing at the moment of payment reads as a bait-and-switch even when the delta is trivial. Forty cents is enough.&lt;/p&gt;

&lt;p&gt;Compute the full total, including tax and fees, on the cart screen, with a breakdown available on tap. The number the user commits to should be the number they pay.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four questions we ask now
&lt;/h2&gt;

&lt;p&gt;Before any payment flow work, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the complete total, including all fees, visible before the sheet opens?&lt;/li&gt;
&lt;li&gt;Is there a biometric confirmation on the final tap, with a sensible fallback?&lt;/li&gt;
&lt;li&gt;Does the tap produce a visible response immediately, ideally by prewarming the sheet?&lt;/li&gt;
&lt;li&gt;Are error states mapped to plain-English copy with a working recovery path?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Visual polish, sheet customisation, and perf tuning all come after these. Not because they don't matter, but because in our testing they didn't move the thing we were trying to move.&lt;/p&gt;

&lt;p&gt;If you're scaffolding a new project rather than repairing one, starting from checkout screens that already have the biometric gate, prewarmed sheet, and mapped error states wired in removes most of this before it exists. It's part of what &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=checkout-trust-not-speed" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates for Expo projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Run the sessions before the sprint, not after.&lt;/p&gt;

&lt;p&gt;We built the perf work because it was legible, measurable, and felt like the responsible engineering choice. It was also unfalsifiable in the only way that mattered: we could prove it worked in a trace and never prove it worked on a person.&lt;/p&gt;

&lt;p&gt;Twelve moderated sessions cost less than the sprint did.&lt;/p&gt;




&lt;p&gt;What's the last checkout change you shipped that moved a real number? I'm collecting the ones that worked, and my suspicion is that almost none of them are performance.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ux</category>
      <category>payments</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
