<?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: Amanda Gama</title>
    <description>The latest articles on DEV Community by Amanda Gama (@aoligama).</description>
    <link>https://dev.to/aoligama</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1453761%2F78200e0f-c511-4bb3-bae3-712f13bfa74c.jpeg</url>
      <title>DEV Community: Amanda Gama</title>
      <link>https://dev.to/aoligama</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aoligama"/>
    <language>en</language>
    <item>
      <title>MVC, MVP, MVVM in React Native: what survives the trip</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Tue, 12 May 2026 02:49:13 +0000</pubDate>
      <link>https://dev.to/aoligama/mvc-mvp-mvvm-in-react-native-what-survives-the-trip-4cg4</link>
      <guid>https://dev.to/aoligama/mvc-mvp-mvvm-in-react-native-what-survives-the-trip-4cg4</guid>
      <description>&lt;p&gt;MVC, MVP, MVVM all come from worlds React Native doesn't fully have. Half of each pattern dies on import. The other half is what most React Native code is already doing under different names. This post is about which half is which.&lt;/p&gt;

&lt;h2&gt;
  
  
  What React Native is missing
&lt;/h2&gt;

&lt;p&gt;Before mapping a pattern onto React Native, notice what isn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No swappable view.&lt;/strong&gt; In Cocoa or WPF, the View is an object you can replace, subclass, or wire to a different controller. In React Native the View is a function call result. There's nothing to swap. The closest equivalent is "render a different component," which isn't the same operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No two-way binding.&lt;/strong&gt; WPF, Knockout, early Angular: the View binds to a property; updating either side updates the other. React went the other way on purpose. State flows down, events flow up, and "binding" is a manual &lt;code&gt;value&lt;/code&gt; plus &lt;code&gt;onChange&lt;/code&gt;. MVVM assumes the binding does work; in React Native, you do that work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No framework-managed event loop.&lt;/strong&gt; MVC and MVP came from worlds where the framework dispatched events to your Controller or Presenter. In React Native the runtime is JS plus React's reconciler. Events arrive at components. There's no router for them above that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Components are functions, not objects.&lt;/strong&gt; This is the one that breaks the most. Every pattern named here was designed around classes with explicit lifecycles. Hooks replaced that with closures and effects. The shapes don't line up.&lt;/p&gt;

&lt;p&gt;You can ignore all of this and write &lt;code&gt;class HomeScreenController extends Controller&lt;/code&gt; if you want. It will compile. It won't feel right, and it will be the only Controller in the app inside a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  The example
&lt;/h2&gt;

&lt;p&gt;One screen, carried through every pattern. A list of items with loading, error, and refresh. Boring on purpose. The interesting part is where the logic ends up.&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;function&lt;/span&gt; &lt;span class="nf"&gt;ItemsScreen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// get list, status, retry, refresh from somewhere&lt;/span&gt;
  &lt;span class="c1"&gt;// render based on status&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where "somewhere" lives is the whole conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  MVC: the Controller has no home
&lt;/h2&gt;

&lt;p&gt;MVC's job split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model&lt;/strong&gt; holds data and rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;View&lt;/strong&gt; renders&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controller&lt;/strong&gt; receives input, mutates the model, tells the view to update&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Cocoa, the Controller is a real object. &lt;code&gt;viewDidLoad&lt;/code&gt;, &lt;code&gt;tableView(_:didSelectRowAt:)&lt;/code&gt;, methods you can put a breakpoint on. In Rails, the Controller is the route handler.&lt;/p&gt;

&lt;p&gt;In React Native, where would you put one?&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;function&lt;/span&gt; &lt;span class="nf"&gt;useItemsController&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;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setState&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;error&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;load&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="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;error&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/items&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&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="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&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="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="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;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;load&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's a hook. We called it a controller. It's still a hook. And it lives inside the component that renders, which is the View. The Controller you can put a breakpoint on, the one that has its own lifecycle, doesn't exist here. Hooks ate it.&lt;/p&gt;

&lt;p&gt;The other failure mode is worse. People interpret "Controller" as "the screen file with all the logic in it" and end up with 600-line components that nobody calls a controller but that play the same role. That isn't MVC. That's MVC's symptom without its discipline.&lt;/p&gt;

&lt;p&gt;What survives: the Model. A real Model layer (use cases, repositories, types) is still useful and still goes in a folder that isn't &lt;code&gt;screens/&lt;/code&gt;. The Controller part has no analog worth defending.&lt;/p&gt;

&lt;h2&gt;
  
  
  MVP: the Presenter, almost
&lt;/h2&gt;

&lt;p&gt;MVP fixed one thing about MVC: the View becomes passive. It only knows how to render and emit events. The Presenter holds the logic and pushes data to the view.&lt;/p&gt;

&lt;p&gt;In Android, this looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ItemsPresenter&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onAttach&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ItemsView&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onLoad&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// calls model, then view.showList(...)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In React Native, the closest analog is a custom hook that returns a fully-shaped props object, paired with a passive component:&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;function&lt;/span&gt; &lt;span class="nf"&gt;useItemsPresenter&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="nx"&gt;setStatus&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&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;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setItems&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;Item&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setError&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="nb"&gt;Error&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setItems&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;getItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;load&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;load&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ItemsView&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="nx"&gt;items&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="nx"&gt;retry&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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;loading&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Spinner&lt;/span&gt; &lt;span class="o"&gt;/&amp;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;error&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ErrorState&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&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="nx"&gt;onRetry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FlatList&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="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;renderItem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{...}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ItemsScreen&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;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useItemsPresenter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ItemsView&lt;/span&gt; &lt;span class="p"&gt;{...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&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;This works. It's testable: you can render &lt;code&gt;ItemsView&lt;/code&gt; with arbitrary props in a Storybook story or a snapshot test, and you can run &lt;code&gt;useItemsPresenter&lt;/code&gt; in &lt;code&gt;renderHook&lt;/code&gt; without touching the UI.&lt;/p&gt;

&lt;p&gt;The friction is honest. You now have three things (&lt;code&gt;screen&lt;/code&gt;, &lt;code&gt;presenter&lt;/code&gt;, &lt;code&gt;view&lt;/code&gt;) where some teams would rather have one. The split feels like ceremony when the screen is small. The first time the same screen needs a tablet variant, an A/B test, or to be reused for a different user role, the split earns its keep.&lt;/p&gt;

&lt;p&gt;What survives: the passive view plus presenter hook split, when the split is worth it. What doesn't: calling it MVP. Most React Native engineers will read it as "container plus presentational component," which is the name React used for the same idea before hooks blurred the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  MVVM: the closest fit, and the most misunderstood
&lt;/h2&gt;

&lt;p&gt;MVVM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model&lt;/strong&gt; as before&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;View&lt;/strong&gt; binds to a ViewModel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ViewModel&lt;/strong&gt; exposes observable state and commands. It doesn't know about the View.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The defining feature is the binding. In WPF, &lt;code&gt;&amp;lt;TextBox Text="{Binding Name}" /&amp;gt;&lt;/code&gt; keeps both sides in sync. The ViewModel never imports the View. The View never asks the ViewModel for data; it observes.&lt;/p&gt;

&lt;p&gt;React Native has no two-way binding. But the spirit of MVVM, a ViewModel that exposes state and commands and doesn't know who's watching, maps surprisingly well onto two things React Native already has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hooks as ViewModels.&lt;/strong&gt; A custom hook that owns state and exposes commands is a ViewModel. The "binding" is React's render cycle: when state changes, the component re-renders. Same outcome as MVVM, achieved with a different mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stores as ViewModels.&lt;/strong&gt; Zustand, Jotai, MobX. A store that holds state and exposes commands is a textbook ViewModel. The component subscribes via a selector. The store has no idea what's rendering.&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;useItemsVM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kd"&gt;set&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;error&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="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&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;getItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ItemsScreen&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useItemsVM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useItemsVM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&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;load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useItemsVM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;load&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;load&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;load&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;loading&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Spinner&lt;/span&gt; &lt;span class="o"&gt;/&amp;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;error&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ErrorState&lt;/span&gt; &lt;span class="nx"&gt;onRetry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;load&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FlatList&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="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;renderItem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{...}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&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;Two properties of MVVM hold here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The ViewModel doesn't import the View. It can be tested by calling &lt;code&gt;useItemsVM.getState().load()&lt;/code&gt; in plain Node, no render tree required.&lt;/li&gt;
&lt;li&gt;The View only reads from the ViewModel; it doesn't reach into the Model directly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you give up versus classical MVVM: the binding is unidirectional, and there's no &lt;code&gt;INotifyPropertyChanged&lt;/code&gt; ceremony. You don't miss it.&lt;/p&gt;

&lt;p&gt;The misunderstood part: most "MVVM in React" articles treat hooks as a flawed approximation. They aren't. They're the same idea with the binding mechanism replaced. If anything, they're cleaner. There's no hidden observer registration, just a render that re-runs when state changes.&lt;/p&gt;

&lt;p&gt;What survives: most of MVVM's intent. ViewModel-as-hook or ViewModel-as-store is a plausible default for any non-trivial screen. What doesn't: data binding. You wire it manually with &lt;code&gt;value&lt;/code&gt; and &lt;code&gt;onChange&lt;/code&gt;, and you stop missing it after a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually survives
&lt;/h2&gt;

&lt;p&gt;Strip the labels. The shared idea behind MVC, MVP, and MVVM is one sentence: keep render separate from logic. They differ on how, on who owns what, on what the framework does for you. They agree that a screen file with API calls, validation, navigation, and rendering is going to hurt.&lt;/p&gt;

&lt;p&gt;Three things survive the trip to React Native:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Model is real.&lt;/strong&gt; Use cases, repositories, domain types. Lives in its own folder. No imports from the framework. (See &lt;a href="//clean-architecture-react-native.md"&gt;clean-architecture-react-native&lt;/a&gt; for how to draw that line.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The View should be small enough to render in a Storybook story.&lt;/strong&gt; If you can't render a component with arbitrary props because it does its own fetching, you've fused the View and ViewModel without meaning to.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Logic lives in a hook or a store, not in the component.&lt;/strong&gt; Whether you call it a presenter, a view model, or just &lt;code&gt;useItems&lt;/code&gt;, the rule is the same: the component's body should mostly be branches on state and event handlers that delegate.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The patterns are different names for the same advice, dressed for the framework that birthed them.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the labels actually help
&lt;/h2&gt;

&lt;p&gt;Two honest cases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onboarding.&lt;/strong&gt; If you join a team that says "we use MVVM," you need to know which part of MVVM they kept and which part they redefined. The label is a starting point, not the answer. The right next questions are "where does the logic live" and "what is the View allowed to know."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-platform conversations.&lt;/strong&gt; If you're talking to a UIKit engineer who writes MVC, an Android engineer used to MVP, or a WPF engineer with two decades of MVVM, the labels are the bridge. Telling them "we use hooks" describes the mechanism, not the architecture. Telling them "the screen is the View, the hook is the ViewModel, the use case is the Model" lets them ask the right follow-ups.&lt;/p&gt;

&lt;p&gt;The bad case for labels is cargo-culting. A folder structure with &lt;code&gt;views/&lt;/code&gt;, &lt;code&gt;viewmodels/&lt;/code&gt;, and &lt;code&gt;models/&lt;/code&gt; doesn't make a codebase MVVM if half the screens still call &lt;code&gt;fetch&lt;/code&gt; directly. Folders are documentation. Lint rules and review are enforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill at month twelve
&lt;/h2&gt;

&lt;p&gt;The patterns are old. The advice underneath them isn't. React Native doesn't get to skip the question of where logic lives just because hooks make it cheap to put it everywhere. The screen file that does its own fetching, validation, and animation will still be the one you debug at midnight, regardless of which acronym you put on the wall.&lt;/p&gt;

&lt;p&gt;Pick a place for logic that isn't the component. Make the component small enough to render with props. Test the logic without rendering. The label matters less than whether the next engineer can find the bug.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>javascript</category>
      <category>mobile</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>Shipping React Native Updates Without the App Store: A Practical Guide to OTA and React Native Stallion</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Tue, 12 May 2026 02:47:45 +0000</pubDate>
      <link>https://dev.to/aoligama/shipping-react-native-updates-without-the-app-store-a-practical-guide-to-ota-and-react-native-557n</link>
      <guid>https://dev.to/aoligama/shipping-react-native-updates-without-the-app-store-a-practical-guide-to-ota-and-react-native-557n</guid>
      <description>&lt;p&gt;You know the feeling. There's a typo in production copy. A misconfigured feature flag. A small logic bug that snuck past QA somehow. None of it touches native code. It's pure JavaScript, the kind of fix you could push in five minutes if the universe were fair. But the universe isn't fair, and so you wait. You wait for Apple. You wait for the Play Store staged rollout. Hours go by. Sometimes days. All for a one-line change.&lt;/p&gt;

&lt;p&gt;This is the gap OTA updates were built to fill. In this article I want to walk through what OTA actually means in React Native land, why everything changed in 2024, and how to get up and running with React Native Stallion, which is probably the most interesting CodePush replacement to come out of that whole mess.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OTA Updates Actually Are (and Aren't)
&lt;/h2&gt;

&lt;p&gt;A React Native app is really two things in a trench coat. There's the native shell, which is your APK or IPA with all its Java, Kotlin, Objective-C and Swift. And then there's the JavaScript bundle, which is just a file that the shell loads when the app starts. The app stores care about the shell. The bundle is, from their perspective, content.&lt;/p&gt;

&lt;p&gt;OTA exploits that gap. Instead of resubmitting the whole app, you swap the JavaScript bundle on the device. Next launch (or whenever you decide), your users are running new code.&lt;/p&gt;

&lt;p&gt;What you can ship over the air:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UI changes, copy fixes, styling tweaks&lt;/li&gt;
&lt;li&gt;New features written entirely in JavaScript&lt;/li&gt;
&lt;li&gt;Business logic updates, API integration changes&lt;/li&gt;
&lt;li&gt;Bug fixes that don't touch native modules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Updates to native modules. Added a new library with native code? Time to rebuild.&lt;/li&gt;
&lt;li&gt;Permission changes. Camera, location, notifications, all of that.&lt;/li&gt;
&lt;li&gt;App icons, splash screens, native configuration&lt;/li&gt;
&lt;li&gt;Anything below the JavaScript layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both Apple and Google allow JavaScript-only OTA. They've been pretty consistent about it. What they don't allow is using OTA to fundamentally change what your app does after the fact. Hot-fixing bugs is fine. Quietly turning your meditation app into a casino is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Whole Landscape Shifted
&lt;/h2&gt;

&lt;p&gt;For years the answer to "how do we do OTA on React Native" was basically: CodePush, paired with App Center, both from Microsoft. It was free, it was well-documented, it worked. Most teams just used it and got on with their lives.&lt;/p&gt;

&lt;p&gt;Then in 2024 Microsoft announced they were retiring App Center, with the full shutdown landing in 2025. CodePush as a hosted service went with it.&lt;/p&gt;

&lt;p&gt;That left a lot of teams suddenly squinting at their production apps wondering what now. The replacements that emerged:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expo EAS Update&lt;/strong&gt; is polished and well-maintained, but tightly coupled to the Expo ecosystem. If you're already there, great. If you're on bare React Native, there's friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosted CodePush&lt;/strong&gt; is technically possible since the server code is open source. The catch is you're now running infrastructure, scaling it, securing it, and on call for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React Native Stallion&lt;/strong&gt; is newer, fully managed, and was built specifically to fill the CodePush void. It also makes some interesting technical bets (differential patches, bundle signing) that the older tools don't.&lt;/p&gt;

&lt;p&gt;The rest of this article focuses on Stallion, because it's what I keep seeing teams reach for when they're migrating off CodePush and don't want to fully commit to the Expo workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stallion Actually Gives You
&lt;/h2&gt;

&lt;p&gt;A few things stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Patch updates.&lt;/strong&gt; Traditional OTA ships the entire JavaScript bundle every time. Your bundle is 20 MB, you fix a one-line bug, every user on the planet downloads 20 MB. Stallion computes a file-level diff and ships only the changed bytes. Their docs claim up to 98% size reduction depending on the change, and from what I've seen that's roughly right. On slow connections this is the difference between an update finishing in seconds and an update never finishing at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket-based testing and promotion.&lt;/strong&gt; Bundles get uploaded to buckets and promoted to production from the dashboard. You can also push a build to internal testers without going through TestFlight or the Play Console, which is honestly the part QA teams get most excited about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic and manual rollback.&lt;/strong&gt; Misbehaving release? You can pause it (stops new downloads) or roll it back (reverts existing devices on next launch). Crash-on-startup automatic rollback is also baked in, which I'll get to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bundle signing.&lt;/strong&gt; Every bundle is cryptographically signed, with optional customer-managed keys. This matters more than people realize. An unsigned OTA pipeline is basically a supply chain attack waiting to happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open source SDK and CLI.&lt;/strong&gt; MIT licensed. The console is hosted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting It Up
&lt;/h2&gt;

&lt;p&gt;Stallion has three pieces. The CLI uploads bundles. The SDK on the device downloads and applies them. The console is where you manage releases. You'll need React Native 0.69 or higher.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Install the SDK and CLI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# in your React Native project&lt;/span&gt;
npm i react-native-stallion

&lt;span class="c"&gt;# globally or in your CI tooling&lt;/span&gt;
npm i &lt;span class="nt"&gt;-g&lt;/span&gt; stallion-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;npx pod-install&lt;/code&gt; to link iOS.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Wire Up the Native Side
&lt;/h3&gt;

&lt;p&gt;You're telling React Native to load its JavaScript bundle from Stallion's storage instead of the default location. On Android that means overriding &lt;code&gt;getJSBundleFile&lt;/code&gt;. On iOS, &lt;code&gt;bundleURL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android (React Native 0.76+, Kotlin):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.stallion.Stallion&lt;/span&gt;

&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;reactNativeHost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ReactNativeHost&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
  &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;DefaultReactNativeHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getJSBundleFile&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="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stallion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getJSBundleFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;applicationContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;iOS (React Native 0.76+, Swift):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="n"&gt;react_native_stallion&lt;/span&gt;

&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;bundleURL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cp"&gt;#if DEBUG&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;RCTBundleURLProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sharedSettings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jsBundleURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forBundleRoot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="cp"&gt;#else&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;StallionModule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBundleURL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="cp"&gt;#endif&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;#if DEBUG&lt;/code&gt; matters. In development you want Metro serving fresh bundles, not Stallion. OTA only kicks in for release builds.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Add Your Project ID and App Token
&lt;/h3&gt;

&lt;p&gt;Generate both from the Stallion console under &lt;code&gt;Project Settings &amp;gt; Access Tokens&lt;/code&gt;, then drop them into your native config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS&lt;/strong&gt;, in &lt;code&gt;Info.plist&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StallionProjectId&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;your_project_id&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StallionAppToken&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;spb_your_app_token_here&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Android&lt;/strong&gt;, in &lt;code&gt;res/values/strings.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;string&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"StallionProjectId"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;your_project_id&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"StallionAppToken"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;spb_your_app_token_here&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Wrap Your Root Component
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;withStallion&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-stallion&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;App&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// your app&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="nf"&gt;withStallion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;App&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole setup. A new release build of your app will now check Stallion for updates on launch and when it returns to the foreground.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing and Promoting an Update
&lt;/h2&gt;

&lt;p&gt;The actual workflow is pretty boring once you've done it once, which is what you want.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make your code change.&lt;/li&gt;
&lt;li&gt;Build the JavaScript bundle and upload it to Stallion via the CLI.&lt;/li&gt;
&lt;li&gt;In the console, pick the bundle and click "Promote Bundle." You specify the target app version, write release notes, and optionally set a rollout percentage.&lt;/li&gt;
&lt;li&gt;Next time a user's app comes to the foreground, it checks for updates and pulls the new bundle in the background.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rollout percentage is the part worth lingering on. By default new releases start at 0%, meaning only users explicitly logged into the SDK (typically your team) get the update. You then dial it up. 5%, 25%, 50%, 100%, watching adoption and crash metrics as you go. It's the same phased rollout pattern you'd use on the Play Store, except you're holding the dial directly instead of submitting paperwork to Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Update UX
&lt;/h2&gt;

&lt;p&gt;By default Stallion downloads silently in the background and the new bundle takes effect on the next cold start. For most apps that's the right behavior. Sometimes though you want to prompt the user, especially for important fixes. "Hey, there's an update ready, want to restart now?"&lt;/p&gt;

&lt;p&gt;The SDK exposes a hook for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useStallionUpdate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;restart&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-stallion&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;UpdateBanner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isRestartRequired&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStallionUpdate&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;isRestartRequired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;View&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&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;banner&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Text&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Restart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;onPress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/View&lt;/span&gt;&lt;span class="err"&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;&lt;code&gt;useStallionUpdate&lt;/code&gt; also gives you metadata about the new bundle, including release notes and version info, so you can surface something meaningful rather than a generic "update available" toast.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Things Go Wrong
&lt;/h2&gt;

&lt;p&gt;This is the part most teams underestimate when they're picking an OTA platform. You will, eventually, ship a broken bundle to production. The question is how fast you can recover.&lt;/p&gt;

&lt;p&gt;Stallion gives you two levers from the dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pause&lt;/strong&gt; stops the release from downloading to any new device. Devices that already have it stay on it. Useful when you've spotted a problem but it's not severe enough to roll back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollback&lt;/strong&gt; pauses the release and tells devices that already pulled it to revert to the previous stable bundle on next launch. This is the nuclear option, and it's the one you want when something is really wrong.&lt;/p&gt;

&lt;p&gt;There's also automatic rollback for the worst case: if a new bundle causes the app to crash before it can mark itself as healthy, the SDK reverts on next launch. This is the safety net that lets you sleep through a 3 AM bad deploy. (Not that you should, but you can.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Some Honest Tradeoffs
&lt;/h2&gt;

&lt;p&gt;OTA is a force multiplier, but it's not free. A few things worth chewing on before you commit.&lt;/p&gt;

&lt;p&gt;You're adding a runtime dependency on a vendor. Stallion is well-maintained and the SDK is open source, but your update pipeline now depends on their availability. Worth understanding their SLA story before you wire critical workflows around it.&lt;/p&gt;

&lt;p&gt;Native code changes still go through the app store. OTA doesn't reduce that cadence. It just lets you patch JavaScript faster between native releases.&lt;/p&gt;

&lt;p&gt;Testing discipline matters more, not less. Faster shipping is also faster shipping of bugs. The teams that get the most out of OTA are the ones with solid CI, automated tests, and the discipline to actually use phased rollouts. Without that you've just built a faster way to break production.&lt;/p&gt;

&lt;p&gt;App Store review policies still apply. Don't use OTA to dodge review. Apple has been clear that meaningful changes to app functionality need to go through the normal process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;If you're running React Native in production in 2026, OTA isn't really optional anymore. Your competitors have it. Your users expect bug fixes in days, not weeks. The "wait for the next release cycle" argument basically never wins against "patch it tonight."&lt;/p&gt;

&lt;p&gt;CodePush is gone. The choices are Expo, build your own, or pick up something like Stallion. For teams on bare React Native who want CodePush-style ergonomics with some modern conveniences (patch updates, signed bundles, dashboard rollouts), Stallion is a reasonable default to evaluate.&lt;/p&gt;

&lt;p&gt;Setup is an afternoon. Using it well takes longer, but that's true of any deployment pipeline. Start with internal beta. Ramp through phased rollouts. Watch your crash metrics. Keep the rollback button close.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>mobile</category>
      <category>reactnative</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>State management in React: when Redux, Zustand, and Context API actually fit</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Sun, 10 May 2026 04:27:04 +0000</pubDate>
      <link>https://dev.to/aoligama/state-management-in-react-when-redux-zustand-and-context-api-actually-fit-43bp</link>
      <guid>https://dev.to/aoligama/state-management-in-react-when-redux-zustand-and-context-api-actually-fit-43bp</guid>
      <description>&lt;p&gt;Every React project hits the same fork around month two. Prop drilling gets old. Someone says "we need state management." The next two days are an opinion war. Redux because it's what we know. Zustand because someone read a blog post. Context because "isn't that built in?"&lt;/p&gt;

&lt;p&gt;The mistake isn't picking the wrong tool. It's not noticing that the three you're choosing between solve different problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each one actually is
&lt;/h2&gt;

&lt;p&gt;Quick mental models. Most of the confusion lives there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context API&lt;/strong&gt; is a transport mechanism, not a state library. It lets a value cross the tree without prop drilling. That's it. The "state" that lives in a context is whatever you put in a &lt;code&gt;useState&lt;/code&gt; near the provider. When that value's identity changes, every consumer re-renders. No selector layer, no batching, no devtools. It's a wire, not a store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redux&lt;/strong&gt; is a single store with predictable, traceable updates. One reducer, one source of truth, every change goes through the same pipeline so you can replay it. Time travel debugging falls out of the design.&lt;/p&gt;

&lt;p&gt;The Redux you remember is probably not the Redux anyone writes today. The action types, the action creators, the reducer switch statements, the &lt;code&gt;connect&lt;/code&gt; HOC, the &lt;code&gt;mapStateToProps&lt;/code&gt;, the boilerplate that made Redux a punchline: that's old Redux. Redux Toolkit (RTK) is what you'd actually write now. The trauma is real, the boilerplate isn't, and that gap does a lot of unfair work in most "Redux vs X" comparisons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zustand&lt;/strong&gt; is a hook around a store. No provider, no reducer, no actions. You define a store, you read from it with a selector, you call methods on it to update. Closer to "&lt;code&gt;useState&lt;/code&gt; that lives outside the component" than to a Flux-style architecture.&lt;/p&gt;

&lt;p&gt;The same trivial example in all three, just to feel the gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Context API&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&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;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setUser&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;User&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Provider&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setUser&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;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/UserContext.Provider&lt;/span&gt;&lt;span class="err"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UserContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Redux Toolkit&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSlice&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;User&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="na"&gt;reducers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PayloadAction&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;configureStore&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reducer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userSlice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reducer&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSelector&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RootState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dispatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useDispatch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userSlice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Zustand&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;useUserStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&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;set&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;user&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="na"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;user&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useUserStore&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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;setUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useUserStore&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a fair fight on its own (Redux earns its weight elsewhere, we'll get there). At the trivial end, though, the boilerplate gap is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest aside before going further
&lt;/h2&gt;

&lt;p&gt;Before going further: a big chunk of the state you think is client state is actually server state. The user's profile from the API. The list of orders. The dashboard data. None of that is "state your app owns." It's a cache of something the server owns.&lt;/p&gt;

&lt;p&gt;TanStack Query (or SWR, or Apollo) handles server state better than any of these three. Caching, revalidation, request deduping, stale-while-revalidate, background refresh, retry: they exist as features there, not in Redux or Zustand. If you're reaching for a state library to hold an array you just fetched, you're reaching for the wrong thing.&lt;/p&gt;

&lt;p&gt;The rest of this post is about state your client actually owns: UI state, ephemeral state that spans the app, form draft state, things only your app cares about.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Context API wins
&lt;/h2&gt;

&lt;p&gt;Values that rarely change but need to cross the tree.&lt;/p&gt;

&lt;p&gt;Theme. Locale. Current user (if you're not using a server state library to fetch them). Feature flags. Whether the app is online. The kind of data set once at app start and rarely changes after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Theme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ThemeContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Theme&lt;/span&gt;
  &lt;span class="na"&gt;setTheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Theme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ThemeProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&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;theme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setTheme&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;Theme&lt;/span&gt;&lt;span class="o"&gt;&amp;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;light&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setTheme&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ThemeContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Provider&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/ThemeContext.Provider&lt;/span&gt;&lt;span class="err"&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;Why Context works here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's already in React. No bundle cost, no library decision.&lt;/li&gt;
&lt;li&gt;The value rarely changes, so re-rendering every consumer doesn't bite.&lt;/li&gt;
&lt;li&gt;You're modeling something that's genuinely local to a part of the tree, not global.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trap is using Context for state that changes constantly. A form context that updates on every keystroke and re-renders the whole form tree is the bad case everyone hits. People feel the lag, blame React, and reach for memoization instead of recognizing that Context wasn't built for that.&lt;/p&gt;

&lt;p&gt;Rough test: if the value updates more than a couple of times per minute, or has more than a few unrelated consumers, Context is going to cost you. Reach for a store.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Zustand wins
&lt;/h2&gt;

&lt;p&gt;Most apps that aren't huge.&lt;/p&gt;

&lt;p&gt;Zustand fits the case where you want a store without the architecture lecture. No provider. No reducer split. Async lives in regular functions on the store, not in middleware. TypeScript inference works without four lines of generic incantations. Selectors mean components only re-render when the slice they care about changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CartState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CartItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="na"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CartItem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;
  &lt;span class="na"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="k"&gt;void&lt;/span&gt;
  &lt;span class="na"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;useCartStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CartState&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;set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;get&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;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="na"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="na"&gt;checkout&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/checkout&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;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}))&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CartCount&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;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCartStore&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&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;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&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;&lt;code&gt;CartCount&lt;/code&gt; re-renders when the count changes. Updating a quantity on an existing item without changing length doesn't re-render it. That selector behavior does real work.&lt;/p&gt;

&lt;p&gt;Async is the part where Zustand quietly wins. No debate over thunks versus sagas, no extra dependency. The store action is an &lt;code&gt;async&lt;/code&gt; function. It awaits, it sets, it's done. That's the whole pattern.&lt;/p&gt;

&lt;p&gt;The catch: Zustand stays small and ergonomic by not giving you much structure. At very large team sizes, with many engineers touching the same store, you start writing your own conventions for splitting stores, organizing actions, handling communication between stores. By the time you've done that for a while, you've reinvented a worse Redux. That's not a reason to skip Zustand. It's a reason to notice the moment when the lack of structure stops being a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Redux (RTK) wins
&lt;/h2&gt;

&lt;p&gt;Larger teams, larger surface area, complex async, and the case where DevTools earn their keep.&lt;/p&gt;

&lt;p&gt;The honest sell for Redux today is the ecosystem. Redux DevTools is still the best experience for debugging state in any framework. Time-travel works. Action history is a real thing you can scroll back through. RTK Query, if you go with it instead of TanStack Query, is genuinely solid for server state and integrates with the rest of the store. Listener middleware (the new alternative to sagas) covers most of the cases that used to need extra libraries for side effects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;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;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cartSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSlice&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;CartItem&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;reducers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;itemAdded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PayloadAction&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CartItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;itemRemoved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PayloadAction&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;extraReducers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;builder&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;builder&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&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;loading&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="nf"&gt;addCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfilled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&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;span class="nf"&gt;addCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checkout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAsyncThunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cart/checkout&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;getState&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;RootState&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/checkout&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;items&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;Noticeably more file than the Zustand version. Some of the noise is genuine: explicit action types are what make time travel possible, and &lt;code&gt;extraReducers&lt;/code&gt; is what lets you express loading, success, and error in one place. Some of it is just RTK being thorough about TypeScript.&lt;/p&gt;

&lt;p&gt;When that overhead pays for itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More than a handful of engineers touch the same state. The structure stops being a tax and starts being a contract.&lt;/li&gt;
&lt;li&gt;You depend on DevTools for debugging. During incident response, replaying a sequence of actions to reproduce a bug is a superpower.&lt;/li&gt;
&lt;li&gt;You have complex middleware needs: throttling, debouncing, coordination between stores, optimistic updates with conflict resolution. The listener middleware story is good.&lt;/li&gt;
&lt;li&gt;You've already committed to RTK Query for server state. Mixing it with Zustand isn't impossible, but now you're running two state systems.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When it doesn't: small to mid apps, solo dev or small team, no exotic patterns for side effects. RTK isn't bad there. It's just paying for capabilities you won't use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dimensions you feel later
&lt;/h2&gt;

&lt;p&gt;The stuff that doesn't show up in a tutorial but matters in month six.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DevTools.&lt;/strong&gt; Redux: best in class. Zustand: middleware that gives you the same Redux DevTools, basic but works. Context: nothing, you're back to &lt;code&gt;console.log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async and side effects.&lt;/strong&gt; Redux: thunks for simple, listener middleware for complex, RTK Query for server. Zustand: async functions in the store, that's it. Context: not its job, bring your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript.&lt;/strong&gt; Zustand: clean inference, minimal ceremony. RTK: good but verbose, &lt;code&gt;PayloadAction&lt;/code&gt; and &lt;code&gt;RootState&lt;/code&gt; show up everywhere. Context: fine for static types, awkward when you want a non-null guarantee inside consumers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance.&lt;/strong&gt; Zustand and Redux both subscribe through selectors, so components only re-render when their slice changes. Context re-renders every consumer on every value change unless you split providers or memoize aggressively. For state that changes constantly, Context will be the bottleneck before any of the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing.&lt;/strong&gt; Zustand stores are plain functions, you can call them in a test without a renderer. RTK reducers are pure, also easy to test. Context-based state usually means rendering a tree, which is heavier and slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I reach for now
&lt;/h2&gt;

&lt;p&gt;In practice, I default to two options: Zustand, or nothing.&lt;/p&gt;

&lt;p&gt;For small apps, nothing usually means Context for the few values that need to cross the tree (auth, theme, locale) and &lt;code&gt;useState&lt;/code&gt; everywhere else. That covers more apps than people think. The "you need state management" reflex kicks in earlier than the actual need does.&lt;/p&gt;

&lt;p&gt;For anything bigger, Zustand. The boilerplate cost is low enough that I'll reach for it the moment a value needs to be touched from two unrelated parts of the tree. Selectors handle re-renders, async fits in the store, TypeScript stays out of the way. I don't reach for Redux on new projects, not because RTK is bad, but because I keep finding I don't need what it gives me.&lt;/p&gt;

&lt;p&gt;I'd reach for Redux if I were joining a team that already uses it (don't fight that battle), or starting something where DevTools and a strict structure across a large team are the actual problem to solve. Those are real cases. They're just not most cases.&lt;/p&gt;

&lt;p&gt;The decision isn't "which one is best." It's "which problem do I actually have." Context if you have prop drilling and not much else. Zustand if you have shared client state and want a store without ceremony. Redux if you have a team and a surface area large enough to make the structure pay rent.&lt;/p&gt;

&lt;p&gt;Pick the smallest one that fits, and trade up only when you feel the seams.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>react</category>
    </item>
    <item>
      <title>Biometric login on React Native with Keychain</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Thu, 07 May 2026 02:57:37 +0000</pubDate>
      <link>https://dev.to/aoligama/biometric-login-on-react-native-with-keychain-462j</link>
      <guid>https://dev.to/aoligama/biometric-login-on-react-native-with-keychain-462j</guid>
      <description>&lt;p&gt;Adding Face ID and Touch ID to a React Native app sounds like a single API call. It isn't. There are three pieces you have to wire together: Keychain for storage, the biometric prompt for the user gate, and your auth code for what to do with the result. &lt;code&gt;react-native-keychain&lt;/code&gt; handles the first two well. This post is about wiring all three.&lt;/p&gt;

&lt;p&gt;I'll walk through the mental model, a storage pattern that's served me well, the configuration flags that matter, and how to deal with the prompt errors you'll see in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;Biometrics on mobile aren't a separate API. They're a gate on storage.&lt;/p&gt;

&lt;p&gt;Keychain (iOS) and Keystore (Android) are key/value stores. The values live in hardware-backed secure storage on each platform. &lt;code&gt;react-native-keychain&lt;/code&gt; exposes them with &lt;code&gt;setGenericPassword&lt;/code&gt; and &lt;code&gt;getGenericPassword&lt;/code&gt;. That's it.&lt;/p&gt;

&lt;p&gt;The "biometric login" part is configuration on those two calls. When you write a value, you mark it as biometric-protected. When you read it, the OS prompts the user before handing it back. There's no separate "log in with Face ID" function. You ask Keychain for a value, and the OS decides whether to give it to you.&lt;/p&gt;

&lt;p&gt;That's worth sitting with for a second, because it shapes everything else. The biometric prompt is a side effect of trying to read a piece of data. Once you treat it that way, the rest of the design gets a lot simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two services pattern
&lt;/h2&gt;

&lt;p&gt;You'll end up with at least two namespaces, called "services" in the API. One holds your access token. The other holds a biometric-gated value that exists only to verify the user is present.&lt;/p&gt;

&lt;p&gt;The access token namespace is unlocked. Your app reads it on every API call to attach to the &lt;code&gt;Authorization&lt;/code&gt; header. You really don't want a Face ID prompt every time you fetch a list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="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;Keychain&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-keychain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Keychain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setGenericPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;access&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;access-token&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;The biometric-gated namespace stores something the user proves they have access to. The exact value matters less than the act of retrieving it. A random per-device string works fine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Keychain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setGenericPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;biometric-pass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;randomValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;biometric-gate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;accessControl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Keychain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ACCESS_CONTROL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BIOMETRY_CURRENT_SET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;accessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Keychain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ACCESSIBLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WHEN_UNLOCKED_THIS_DEVICE_ONLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;authenticationPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sign in to YourApp&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;The flow is: read the gated value (which prompts), and on success, go ahead and use the access token. The gated value isn't the token itself. It's the proof that the current user can use the token.&lt;/p&gt;

&lt;p&gt;The reason to keep these separate is background work. A push-notification handler that fetches data needs the access token, but it shouldn't pop a Face ID prompt when there's no UI on screen. Token in an unlocked service, biometrics on a separate service. Background reads stay quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The access control flags that actually matter
&lt;/h2&gt;

&lt;p&gt;Three options on &lt;code&gt;setGenericPassword&lt;/code&gt; decide everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;accessControl&lt;/code&gt;&lt;/strong&gt; is what makes a value biometric. The two values you'll use most are &lt;code&gt;BIOMETRY_CURRENT_SET&lt;/code&gt; on iOS and &lt;code&gt;BIOMETRY_ANY&lt;/code&gt; on Android. They aren't quite symmetric. &lt;code&gt;BIOMETRY_CURRENT_SET&lt;/code&gt; invalidates the value if the user adds or removes a fingerprint or face. Tighter security, more re-enrollments. &lt;code&gt;BIOMETRY_ANY&lt;/code&gt; doesn't invalidate on enrollment changes. More convenient, less strict.&lt;/p&gt;

&lt;p&gt;iOS gives you stricter invalidation by default. Android gives you a wider gate. &lt;code&gt;BIOMETRY_CURRENT_SET&lt;/code&gt; on iOS is a reasonable default unless you have a reason to relax it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;accessible&lt;/code&gt;&lt;/strong&gt; controls when the value is readable. A safe default is &lt;code&gt;WHEN_UNLOCKED_THIS_DEVICE_ONLY&lt;/code&gt;. The value is readable only when the device is unlocked, and it doesn't sync via iCloud Keychain to other devices. If the user gets a new phone, they re-enroll. That's the right behavior. You don't want auth state hopping between devices on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;authenticationPrompt&lt;/code&gt;&lt;/strong&gt; is the title shown in the OS prompt. Keep it short. The user already knows the prompt is for your app, so you don't need to repeat the app name in the title.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on Android string length
&lt;/h2&gt;

&lt;p&gt;One Android quirk worth knowing about. The cipher used for biometric-protected values has a maximum block size, and you can hit &lt;code&gt;IllegalBlockSize&lt;/code&gt; if the stored value is long or has characters that expand under the underlying encoding.&lt;/p&gt;

&lt;p&gt;In practice, keeping the biometric-gated value to 30 alphanumeric characters or fewer avoids the issue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Platform&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formatForKeychain&lt;/span&gt; &lt;span class="o"&gt;=&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="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="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;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="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;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only matters for the biometric-gated value, where the goal is presence verification, not preserving the original data. Your access token in the unlocked service can be any length.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading it back: the prompt and its error modes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getGenericPassword&lt;/code&gt; either returns the credentials or throws. The throw is where most of the work lives.&lt;/p&gt;

&lt;p&gt;The errors come back as messages on the JS side. iOS messages are localized, so matching on the English string fails for users in other languages. The trick is to match on stable parts. Numeric error code on Android. Symbolic error name on iOS. English message as a fallback for older OS versions.&lt;/p&gt;

&lt;p&gt;Three cases are worth telling apart:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User canceled.&lt;/strong&gt; They tapped "Cancel" or the back button. Close the prompt quietly. No error UI, no logging as an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Biometric mismatch.&lt;/strong&gt; The user's face or fingerprint wasn't recognized. Show "try again" or fall back to password. Soft failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything else.&lt;/strong&gt; Hardware unavailable, biometric not enrolled on the device, an unexpected library state. These are the ones worth logging.&lt;/p&gt;

&lt;p&gt;Helpers I've used:&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;function&lt;/span&gt; &lt;span class="nf"&gt;isBiometricUserCanceled&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sr"&gt;/code:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*1&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;03&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="sr"&gt;/laerrorusercanceled/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="sr"&gt;/user canceled the operation/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isBiometricMismatch&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sr"&gt;/code:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*7&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="sr"&gt;/the user name or passphrase you entered is not correct/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Android codes come from &lt;code&gt;BiometricPrompt&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;7&lt;/code&gt; is &lt;code&gt;ERROR_BIOMETRIC_AUTHENTICATION_FAILED&lt;/code&gt; (didn't recognize)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;10&lt;/code&gt; is &lt;code&gt;ERROR_USER_CANCELED&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;13&lt;/code&gt; is &lt;code&gt;ERROR_NEGATIVE_BUTTON&lt;/code&gt; (the user tapped your "cancel" button)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The iOS names come from &lt;code&gt;LAError&lt;/code&gt;. &lt;code&gt;LAErrorUserCanceled&lt;/code&gt; is locale-independent because it's the symbol name. The English fallback in the regex catches older iOS versions where the symbol doesn't appear in the bridged error.&lt;/p&gt;

&lt;p&gt;Match on codes first, message second, and treat anything unmatched as the "real error" bucket. That keeps your crash reporter useful. Cancels and mismatches are user actions; only the unexpected stuff needs your attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few practical recommendations
&lt;/h2&gt;

&lt;p&gt;A few opinions, learned the slow way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the biometric-protected value only as a presence proof.&lt;/strong&gt; Background flows need your access token without a prompt. Token in an unlocked service, biometrics on a separate service. That's what makes background work, well, work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the stored value opaque.&lt;/strong&gt; Don't try to pack metadata into the Keychain key or value. If you need metadata about the user or session, put it in &lt;code&gt;AsyncStorage&lt;/code&gt; keyed off the service name. Keychain values are awkward to migrate. AsyncStorage isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log biometric errors with structured tags.&lt;/strong&gt; Operation, OS, error class. The first time biometric login breaks for a real user, you want to know which class. The second time, you want to know if it's the same class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test on real hardware before shipping.&lt;/strong&gt; iOS Simulator's "Match Touch ID" menu is fine for happy paths. It doesn't reproduce the cancel and mismatch error shapes you'll see on real devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The pieces are simple once you separate them. Keychain is a key/value store with hardware backing. Biometrics are a gate on retrieval. Two services keep background reads from popping prompts. Pick &lt;code&gt;accessControl&lt;/code&gt; and &lt;code&gt;accessible&lt;/code&gt; based on how strict you want invalidation and how portable you want the value to be. Match errors on codes, not localized messages. Test on real devices.&lt;/p&gt;

&lt;p&gt;Done right, "log in with Face ID" is a one-tap experience that keeps working when the user changes their fingerprint, switches devices, or hits a flaky network during a refresh. Most of the work isn't in the library. It's in the small decisions around it.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>reactnative</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Spec-Driven Development: Slowing Down to Ship Faster</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Wed, 06 May 2026 00:53:04 +0000</pubDate>
      <link>https://dev.to/aoligama/spec-driven-development-slowing-down-to-ship-faster-995</link>
      <guid>https://dev.to/aoligama/spec-driven-development-slowing-down-to-ship-faster-995</guid>
      <description>&lt;p&gt;For a while, AI coding tools made me feel productive in a way that worried me. I'd describe a feature, get 200 lines of code in 30 seconds, skim it, ship it. A week later I'd be back fixing the edge cases I never thought about, because the model didn't either.&lt;/p&gt;

&lt;p&gt;That's the gap &lt;strong&gt;Spec-Driven Development (SDD)&lt;/strong&gt; fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually is
&lt;/h2&gt;

&lt;p&gt;Write the spec first. Code second. The spec defines goals, constraints, edge cases, and acceptance criteria, and it becomes the source of truth that AI agents (and humans) implement against.&lt;/p&gt;

&lt;p&gt;A practical spec isn't a Word doc with three layers of headings. It's closer to a contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Feature: Payments CRUD&lt;/span&gt;
&lt;span class="gu"&gt;## Goal&lt;/span&gt;
Allow internal users to create, view, update, and refund customer payments.
&lt;span class="gu"&gt;## Functional requirements&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Create payment with amount, currency, customer ID, and method
&lt;span class="p"&gt;-&lt;/span&gt; List payments with pagination and filters (status, date range)
&lt;span class="p"&gt;-&lt;/span&gt; Update payment metadata (description, tags)
&lt;span class="p"&gt;-&lt;/span&gt; Issue full or partial refunds
&lt;span class="gu"&gt;## Non-goals&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Recurring billing (v1)
&lt;span class="p"&gt;-&lt;/span&gt; Multi-currency conversion at checkout (v1)
&lt;span class="gu"&gt;## Edge cases&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Refund exceeds original amount
&lt;span class="p"&gt;-&lt;/span&gt; Update on a payment already refunded
&lt;span class="p"&gt;-&lt;/span&gt; Concurrent refund requests on the same payment
&lt;span class="gu"&gt;## Acceptance criteria&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; All endpoints return 4xx on invalid input with a structured error
&lt;span class="p"&gt;-&lt;/span&gt; Refunded payments are immutable except for metadata
&lt;span class="p"&gt;-&lt;/span&gt; Audit log records actor and timestamp on every write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short, structured, testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's having a moment
&lt;/h2&gt;

&lt;p&gt;SDD isn't new. BDD, formal methods, and design-first APIs all share its DNA. What changed is that AI can now execute against a spec at production speed. The bottleneck stopped being how fast we type and started being how clearly we describe what we want.&lt;/p&gt;

&lt;p&gt;Without a spec, vibe-coding with an AI agent produces fast, confident, subtly wrong code. With one, the same agent has guardrails it can be measured against.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benefits I've actually felt
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Less rework.&lt;/strong&gt; Most of my "the AI broke it" moments were really "I didn't tell it about the edge case" moments. Writing the spec surfaces those before any code exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better PR reviews.&lt;/strong&gt; Reviewers get the &lt;em&gt;why&lt;/em&gt; alongside the &lt;em&gt;what&lt;/em&gt;. Conversations move from "is this how you wanted it?" to "does this match the spec?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Living documentation, for free.&lt;/strong&gt; The spec lives in the repo, version-controlled, and it's the same artifact onboarding devs read on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better tests.&lt;/strong&gt; Acceptance criteria translate directly into test cases. Coverage becomes a question of "did we cover the spec?" instead of "did we hit 80%?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheaper AI calls.&lt;/strong&gt; Counterintuitive but real. A tight spec means fewer correction loops, which means fewer tokens burned reprompting until something works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveats
&lt;/h2&gt;

&lt;p&gt;SDD isn't waterfall in a trench coat, but it can become that if you over-formalize. Specs that try to enumerate every case grind iteration to a halt. The goal is &lt;em&gt;enough&lt;/em&gt; spec to remove ambiguity, not a 40-page design doc nobody reads.&lt;/p&gt;

&lt;p&gt;It's also harder in brownfield codebases. Reverse-engineering specs from legacy behavior is its own discipline, and worth treating as a separate project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;Pick one upcoming feature. Before opening your editor, write the spec: goal, requirements, non-goals, edge cases, and acceptance criteria. Hand it to your AI agent of choice. Compare the output to what you would have vibe-coded.&lt;/p&gt;

&lt;p&gt;The win isn't writing more docs. It's thinking before you ship.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>A Live Activity isn't a notification</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Tue, 05 May 2026 01:59:54 +0000</pubDate>
      <link>https://dev.to/aoligama/a-live-activity-isnt-a-notification-2k9o</link>
      <guid>https://dev.to/aoligama/a-live-activity-isnt-a-notification-2k9o</guid>
      <description>&lt;p&gt;You're in another app and there's a timer counting down at the top of your phone. You lock the screen and the same timer is sitting there. You swipe down to the Notification Center and it's there too, still ticking. It looks like a notification, but a notification can't tick.&lt;/p&gt;

&lt;p&gt;That's a Live Activity. It looks like three different surfaces (Dynamic Island, lock-screen banner, Notification Center entry), but they're the same widget, rendered three ways by the OS. I wired one up for Tomoe, a focus timer I built. The punch line: it took a weekend. Most of that weekend was unlearning what I thought a Live Activity was. Once the shape clicked, the code was small.&lt;/p&gt;

&lt;p&gt;This post is the shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Live Activity actually is
&lt;/h2&gt;

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

&lt;p&gt;Three things that get glossed over in most tutorials:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's a widget, not a notification.&lt;/strong&gt; The view code lives in a Widget Extension target, separate from your app. The app pushes state; the extension renders pixels. If you've shipped a Home Screen widget before, this is the same story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's driven by a typed &lt;code&gt;ContentState&lt;/code&gt;.&lt;/strong&gt; You declare a Codable struct of "things that change" and call &lt;code&gt;.update()&lt;/code&gt; with a new instance. There's no general "set arbitrary text" API. The schema is the contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The OS renders the timer.&lt;/strong&gt; If you reach for &lt;code&gt;Timer&lt;/code&gt; or a 1-second &lt;code&gt;TimelineView&lt;/code&gt;, you're already off the path. SwiftUI's &lt;code&gt;Text(timerInterval:countsDown:)&lt;/code&gt; lets the OS rasterise the countdown for you. Your widget doesn't wake every second. It can't; the budget would never allow it.&lt;/p&gt;

&lt;p&gt;Two pieces of plumbing before any of this works. Declare &lt;code&gt;NSSupportsLiveActivities&lt;/code&gt; in the app's &lt;code&gt;Info.plist&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSSupportsLiveActivities&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and target iOS 16.2 or later. The 16.0 / 16.1 ActivityKit surface churned, and the workarounds aren't worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;Every Live Activity is keyed by an &lt;code&gt;ActivityAttributes&lt;/code&gt; type. You split it into "stuff that's fixed for the life of the activity" (the attributes themselves) and "stuff that changes" (the nested &lt;code&gt;ContentState&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Tomoe's looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;TomoeActivityAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ActivityAttributes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;ContentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Codable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Hashable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isPaused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;pausedRemainingSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The task name doesn't change once a session starts, so it's an attribute. The end timestamp, pause flag, and the snapshot for rendering paused state all change, so they're in &lt;code&gt;ContentState&lt;/code&gt;. Codable + Hashable is how the OS serialises state across the app/widget process boundary.&lt;/p&gt;

&lt;p&gt;The detail that costs everyone an afternoon: &lt;strong&gt;this file has to be a member of both targets&lt;/strong&gt;, the app target and the widget extension target. Xcode won't warn you. The activity will start, and then the widget will silently fail to decode the state and render nothing. Check the file inspector before you debug anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four Dynamic Island slots
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj6derhc8b14h3qgg3s9z.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj6derhc8b14h3qgg3s9z.jpeg" alt=" " width="800" height="127"&gt;&lt;/a&gt;&lt;br&gt;
Here's where the mental model clicks. The &lt;code&gt;DynamicIsland { … }&lt;/code&gt; builder gives you four slots, each for a different state of the same activity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;compactLeading&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;compactTrailing&lt;/code&gt;&lt;/strong&gt;: the two tiny views you see hugging the camera cutout when only your activity is active.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;minimal&lt;/code&gt;&lt;/strong&gt;: what you're demoted to when &lt;em&gt;another&lt;/em&gt; app also has an active Live Activity. You're now a circle next to a dot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expanded&lt;/code&gt;&lt;/strong&gt;: what the user sees when they long-press.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus the lock-screen / Notification Center view, which is the top-level body of the &lt;code&gt;ActivityConfiguration&lt;/code&gt;. The same view code renders in both places. The OS just changes the chrome around it.&lt;/p&gt;

&lt;p&gt;Trimmed to the load-bearing parts, Tomoe's whole widget is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;TomoeWidgetLiveActivity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;WidgetConfiguration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;ActivityConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TomoeActivityAttributes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;LockScreenView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activityBackgroundTint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activitySystemActionForegroundColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;dynamicIsland&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;DynamicIsland&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;DynamicIslandExpandedRegion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;leading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* mark + task name */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="kt"&gt;DynamicIslandExpandedRegion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trailing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="kt"&gt;TimerView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;white&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="kt"&gt;DynamicIslandExpandedRegion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bottom&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="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPaused&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"paused"&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="nv"&gt;compactLeading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;TomoeMark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;compactTrailing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;TimerView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;white&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;minimal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;TomoeMark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;size&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="p"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keylineTint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accent&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;Two things to keep in mind.&lt;/p&gt;

&lt;p&gt;The compact regions are tiny. Tomoe's brand mark is 22pt; the trailing timer is 22pt at a minimum width of 56pt. Anything bigger overflows. Design for ~40pt of width and treat anything more as a bonus.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;minimal&lt;/code&gt; view appears without warning, the moment any other app starts an activity. Don't put information in &lt;code&gt;compactLeading&lt;/code&gt; that needs to also be in &lt;code&gt;minimal&lt;/code&gt;. Those are different layers, and you don't get a transition between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the OS tick
&lt;/h2&gt;

&lt;p&gt;The single most useful API in this whole stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timerInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;countsDown&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="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monospacedDigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You give it a date range; you get back a &lt;code&gt;Text&lt;/code&gt; that the OS counts down for you. It updates without your widget doing anything. This is what makes the timer feel native: no flicker, no off-by-one, no battery cost.&lt;/p&gt;

&lt;p&gt;The catch: you can't pause it. The OS counts to the end of the range, period. So when the user pauses, Tomoe swaps to a static label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPaused&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pausedTimeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pausedRemainingSeconds&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timerInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;countsDown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On pause, I push an update with &lt;code&gt;isPaused: true&lt;/code&gt; and a snapshot of the remaining seconds. The widget renders that snapshot until the next state change. On resume, I push a new &lt;code&gt;endDate = now + remaining&lt;/code&gt; and we're back on the OS-driven interval.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pausedRemainingSeconds&lt;/code&gt; is the non-obvious part: I have to send the value the widget should display, because the widget process has no idea how long ago I paused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pushing updates from the app
&lt;/h2&gt;

&lt;p&gt;The lifecycle is three calls. From Tomoe's bridge module, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;activity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;staleDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;pushType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// later, on pause/resume&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;staleDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// done&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dismissalPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;immediate&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;pushType: nil&lt;/code&gt; means "I'll push state from this app process." That's right for a timer. The app already knows when the user paused or finished. The other option is &lt;code&gt;.token&lt;/code&gt;, which lets you push from a server via APNs. Useful for Uber-style "your driver is here" updates that the app itself can't observe; overkill for anything the foreground app can drive.&lt;/p&gt;

&lt;p&gt;The budget you should know about, so you don't design something that fights it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An activity can live for about 8 hours before the OS forces it stale.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ContentState&lt;/code&gt; is capped at ~4 KB encoded.&lt;/li&gt;
&lt;li&gt;Updates are rate-limited; push every 100ms and the OS quietly drops most of them.&lt;/li&gt;
&lt;li&gt;A stale activity dims on the lock screen but doesn't disappear until you call &lt;code&gt;.end()&lt;/code&gt; or the user dismisses it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than it sounds: &lt;strong&gt;passing an &lt;code&gt;endDate&lt;/code&gt; in the past does not auto-end the activity.&lt;/strong&gt; The countdown will show &lt;code&gt;00:00&lt;/code&gt; and just sit there. You have to call &lt;code&gt;.end()&lt;/code&gt; yourself when the timer reaches zero. In Tomoe I do this from the JS layer when the timer fires, since the app is React Native. But the Swift &lt;code&gt;Activity.request&lt;/code&gt; / &lt;code&gt;.update&lt;/code&gt; / &lt;code&gt;.end&lt;/code&gt; API is the same regardless of host. Flutter, RN, native, doesn't matter; the shape is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shared types break easily.&lt;/strong&gt; The &lt;code&gt;ActivityAttributes&lt;/code&gt; file has to belong to both targets. Add → File Inspector → check both Target Memberships. If your activity starts but the widget renders empty, this is it. Every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic Island regions ignore overflow.&lt;/strong&gt; SwiftUI happily lets you put a wide view in &lt;code&gt;compactTrailing&lt;/code&gt;. The OS will clip it without a warning at build time. Use &lt;code&gt;.minimumScaleFactor(0.6)&lt;/code&gt; and &lt;code&gt;.lineLimit(1)&lt;/code&gt; defensively, especially on numeric content where the digit count varies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asset catalogs are flaky in widget extensions.&lt;/strong&gt; I had a brand image that loaded fine in the simulator and failed silently on device. I switched to a SwiftUI-drawn mark (a coloured rectangle with an SF Symbol on top) and the problem went away. If a widget asset isn't appearing on a real iPhone, that's the first thing to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;What surprised me about Live Activities is how little there is to them once you've named the parts. A typed &lt;code&gt;ContentState&lt;/code&gt;, four Dynamic Island slots, one OS-driven timer view, three lifecycle calls. That's the whole API surface for a focus timer. Most of the hard work happens before you write code: deciding what to put in the compact view, what's worth a long-press to expand, what the paused state should feel like.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://tomoe-bibt.onrender.com/" rel="noopener noreferrer"&gt;Tomoe&lt;/a&gt; to scratch my own itch for a focus timer that doesn't shout. Three calm scenes: rain, stars, fireflies. Four session lengths. No accounts, no tracking, no streaks to break. The timer follows you out of the app, into the Dynamic Island and onto the lock screen, exactly the way this post describes. &lt;a href="https://apps.apple.com/br/app/tomoe/id6762488332?l=en-GB" rel="noopener noreferrer"&gt;Available on the App Store&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>mobile</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Becoming a tech lead, what I wish someone had told me</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Mon, 04 May 2026 13:07:30 +0000</pubDate>
      <link>https://dev.to/aoligama/becoming-a-tech-lead-what-i-wish-someone-had-told-me-10j9</link>
      <guid>https://dev.to/aoligama/becoming-a-tech-lead-what-i-wish-someone-had-told-me-10j9</guid>
      <description>&lt;p&gt;Becoming a tech lead was the goal from pretty early in my career. I had a clear picture of what the role was. More responsibility, more influence over the work, more of the interesting problems landing on my desk because someone had to figure them out and that someone, finally, would be me. It read like the natural next step. The thing you graduate to once you're good enough.&lt;/p&gt;

&lt;p&gt;What that picture didn't include was the part of the job that's about leading people. Not management. I knew management was a separate track. I mean the quieter stuff. Sitting with someone else's frustration and not fixing it for them. Holding a position you're 60% sure of in a room of people who want a confident answer. Realizing the thing blocking the team isn't a technical problem, and that nothing in your background really prepared you for the actual one.&lt;/p&gt;

&lt;p&gt;This is the post I wish someone had written for me when I was still aiming for the role. Not the ladder advice. The hard parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the day
&lt;/h2&gt;

&lt;p&gt;The honest version of the shift isn't what you read in leadership posts. Yes, there are more meetings. Everyone says that. The meeting count is the easy part to see and the easy part to adapt to. The ones I took longer to notice were quieter.&lt;/p&gt;

&lt;p&gt;The first one is your relationship to shipping. As an engineer, the day has a clean ledger. You opened a PR, you closed an issue, you merged a branch. Even on a slow day there's a diff that proves you were here. As a tech lead, half of your most important work doesn't show up that way. You sat in a meeting and changed the direction of a project. You unblocked someone in a thirty-minute conversation. You wrote a short doc that prevented two weeks of wasted work next month. None of that has a green checkmark next to it. The first few months I felt vaguely guilty at the end of every day, because I couldn't point to a thing I'd built. The fix wasn't to build more. It was to stop measuring my day in commits.&lt;/p&gt;

&lt;p&gt;The second is the time horizon. Engineering work runs in days. A bug, a feature, a refactor: a thing you can hold in your head start to finish. The lead work runs in weeks and quarters. A hire decision today shapes the team six months out. A standard you set now shows up in code reviews a year from now. The horizon stretches and the feedback loops stretch with it. You stop knowing whether a call you made was a good one for a long time, and sometimes you never really know.&lt;/p&gt;

&lt;p&gt;The third is that the day stops having a clean end. When you closed the laptop as an engineer, you were done. The remaining work was on a list, sitting still. As a lead, the open threads keep moving while you sleep. Someone hit something hard at 11pm. A project is drifting and you can feel it without anyone saying so. There's always a thing you could think about more. The skill is learning to put it down anyway.&lt;/p&gt;

&lt;p&gt;The fourth is a new kind of fatigue. Coding is mentally taxing in a contained way. You're tired, you go for a walk, you come back. The fatigue from a day of decisions and context switches is different. It doesn't lift the same way. It's lower-grade and longer-tailed. Nobody warned me about it, and I spent the better part of a year thinking I was just under-sleeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What advice I stopped giving
&lt;/h2&gt;

&lt;p&gt;There's a list of things I used to say to junior engineers with full conviction. Most of them I'd picked up from people I respected, and some I'd defended in arguments. Sitting in the lead chair quietly took a few of them apart.&lt;/p&gt;

&lt;p&gt;I stopped telling people to always push back on bad requirements. The advice isn't wrong, exactly. It's too neat. The version I gave didn't account for what bad requirements actually look like in the wild: usually a partial picture from someone who's already negotiated three other constraints you can't see. The right move is rarely "push back." It's "ask what changes if X." Most of the time the requirement isn't bad. You just don't know what it's load-bearing for yet.&lt;/p&gt;

&lt;p&gt;I stopped saying ship fast as a default. Speed matters. The trouble is that ship-fast gets treated as a virtue independent of context, which is how teams end up shipping things that take a year to undo. Some decisions deserve to be slow. Anything that's expensive to reverse (a data model, a hire, a public commitment, a foundational dependency) deserves the time. Speed on the reversible stuff, slowness on the rest. I should have been more careful about which one I was preaching.&lt;/p&gt;

&lt;p&gt;I stopped telling people to always speak up in meetings. The advice was pitched at people who under-contribute, which is a real failure mode. But there's an opposite one I didn't account for: the person who fills every silence and pushes the team into worse decisions because nobody slowed them down. Sometimes the best thing you can do in a room is wait. Let the question sit. See what someone else says.&lt;/p&gt;

&lt;p&gt;I stopped saying the right tech wins. It doesn't. Alignment wins. The team that picked the okay-but-aligned tool will out-ship the team that picked the better-but-contested one almost every time. The energy you spend defending a technical choice is energy you don't spend using it. I'd watched this happen to other teams and thought we were the exception. We weren't.&lt;/p&gt;

&lt;p&gt;The pattern under all of those: the advice I used to give was advice for the version of the job I'd imagined. Sitting in the chair changed which trade-offs I could see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rituals that survived
&lt;/h2&gt;

&lt;p&gt;Most of what I did to stay organized as an engineer didn't survive the role change. Tickets I lived in stopped being where my work happened. The PR queue stopped being a useful daily anchor. The deep-work blocks I'd guarded for years got eaten by a calendar I no longer fully controlled. I had to rebuild a lot of it.&lt;/p&gt;

&lt;p&gt;What survived is small. Two habits, one rule.&lt;/p&gt;

&lt;p&gt;The first habit is a written end-of-day. Five lines, plain text, takes three minutes. What I decided today. What I'm still unsure about. Who's blocked on me. Who I'm blocked on. One thing I want to start with tomorrow. I tried more elaborate systems and they all collapsed within a month. The five-line version has stuck because it's small enough that I actually do it on bad days, which are the days you most need it.&lt;/p&gt;

&lt;p&gt;The second habit is reading code I didn't write, on purpose, every week. Not to review it. Just to read it. As a lead it's easy to drift away from the codebase, and a few weeks of that is enough that your suggestions in design reviews start feeling slightly off to the people doing the work. An hour a week of just reading what the team is shipping keeps the drift smaller. It doesn't make me an expert in everything. It makes me less wrong.&lt;/p&gt;

&lt;p&gt;The rule is: don't bring up pattern-y problems without sitting with them for 24 hours first. Not the urgent ones. Those go straight up. I mean the "something feels off about how we're approaching X" ones. If I raise that in the moment, half the time I'm reacting to one bad meeting, not a real problem. If it still feels true the next day, it's worth bringing up, and I bring it up better. The 24-hour rule has saved me from a lot of needless team-level drama, almost all of it self-inflicted.&lt;/p&gt;

&lt;p&gt;What I assumed would stick and didn't: the deep-work blocks. I tried to defend them and lost. Most days now my best heads-down hour is the one between waking up and the team coming online, not a block I scheduled. Recreating engineer rhythms in a lead's calendar was a year of frustration before I gave up.&lt;/p&gt;

&lt;p&gt;Also gone: my old habit of replying to messages in batches. As an engineer it was a productivity move. As a lead, leaving four people waiting six hours each is its own kind of cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my earlier self
&lt;/h2&gt;

&lt;p&gt;A short list of things I'd say to the version of me who was aiming for this role.&lt;/p&gt;

&lt;p&gt;The job is mostly about people, even the technical parts. The technical parts that are still purely technical mostly aren't yours to do anymore. Make peace with that earlier than I did.&lt;/p&gt;

&lt;p&gt;You don't need to have the answer. You need to make sure the team gets to one. Those are different jobs and they require different muscles. The first one is the one you spent a decade training. The second one is mostly new.&lt;/p&gt;

&lt;p&gt;Confidence will be asked of you in moments when you don't have it. Faking it doesn't work, because people can tell. Saying "I'm not sure, here's how we'll find out" works almost every time, and the people you respect most are the ones who already do this.&lt;/p&gt;

&lt;p&gt;Most of the advice you give right now is for the role you have, not the one you're aiming at. That's not a problem. Just know that some of it will quietly stop being true.&lt;/p&gt;

&lt;p&gt;The role isn't a promotion in the way you imagine. It's a different job. You'll be a beginner again at parts of it, and the bits where you're a beginner will turn out to be the most important bits.&lt;/p&gt;

&lt;p&gt;The hard parts are the job. Not a side effect of it. Not something to optimize away. If you want the role, the part you're nervous about is most of what you'd be doing.&lt;/p&gt;

</description>
      <category>career</category>
      <category>devjournal</category>
      <category>leadership</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Push notifications, the iceberg under one feature</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Mon, 04 May 2026 02:29:09 +0000</pubDate>
      <link>https://dev.to/aoligama/push-notifications-the-iceberg-under-one-feature-4ka</link>
      <guid>https://dev.to/aoligama/push-notifications-the-iceberg-under-one-feature-4ka</guid>
      <description>&lt;p&gt;It's a one-line item on the roadmap. "Send a push notification when X happens." Estimate is two days, three if the backend doesn't have FCM credentials yet. There's a library for it.&lt;/p&gt;

&lt;p&gt;The library is the visible part. The other 90% is platform lifecycle, registration state machines, race conditions with navigation, payload archaeology, and a half-dozen iOS and Android quirks. Nobody writes them down. You learn them after you ship, when the bug reports start coming in.&lt;/p&gt;

&lt;p&gt;I built this stack with custom native modules, wrapping APNs on iOS and FCM on Android directly, instead of reaching for Notifee, React Native Firebase, or OneSignal. The trade was the obvious one. I gave up the abstraction the libraries provide and got control over every edge case in return. The decision wasn't ideological. The failure modes I cared about were already filed against those libraries, unfixed.&lt;/p&gt;

&lt;p&gt;This post is what's underneath. Not the library you import. The work the library is hiding from you, or trying to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The waterline: registration is a state machine, not a function call
&lt;/h2&gt;

&lt;p&gt;The tutorial version is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;sendToBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's actually happening is a few rounds of back-and-forth between the OS, your app, and the push provider. At least three places it can quietly stop working.&lt;/p&gt;

&lt;p&gt;On iOS, registration is split across delegate methods. The token comes back as &lt;code&gt;NSData&lt;/code&gt;, not a string, and you hex-encode it before anyone outside the AppDelegate gets to see it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;UIApplication&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;application&lt;/span&gt;
&lt;span class="nf"&gt;didRegisterForRemoteNotificationsWithDeviceToken&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSData&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;deviceToken&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deviceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;NSMutableString&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;hex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;NSMutableString&lt;/span&gt; &lt;span class="nf"&gt;stringWithCapacity&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;deviceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSUInteger&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deviceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;hex&lt;/span&gt; &lt;span class="nf"&gt;appendFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;@"%02x"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;i&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="n"&gt;NSNotificationCenter&lt;/span&gt; &lt;span class="nf"&gt;defaultCenter&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nl"&gt;postNotificationName:&lt;/span&gt;&lt;span class="s"&gt;@"DeviceTokenRegistered"&lt;/span&gt;
                    &lt;span class="nl"&gt;object:&lt;/span&gt;&lt;span class="nb"&gt;nil&lt;/span&gt;
                  &lt;span class="nl"&gt;userInfo:&lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt; &lt;span class="s"&gt;@"token"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hex&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's the documented part. Three things aren't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS 18+ silently drops the first registration call&lt;/strong&gt; if you make it immediately after the user grants permission. No error, no callback, nothing. A 1.5 to 2 second delay and one retry recovers from it almost every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="n"&gt;dispatch_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dispatch_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DISPATCH_TIME_NOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int64_t&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;NSEC_PER_SEC&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
               &lt;span class="n"&gt;dispatch_get_main_queue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharedApplication&lt;/span&gt; &lt;span class="nf"&gt;isRegisteredForRemoteNotifications&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="n"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharedApplication&lt;/span&gt; &lt;span class="nf"&gt;registerForRemoteNotifications&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;This isn't in any Apple doc. I found it after enough TestFlight users on iOS 18 reported notifications "just not working."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tokens change.&lt;/strong&gt; APNs rotates them on app reinstall, restore-from-backup, or migration to a new device. FCM rotates on its own schedule. If you don't dedupe registrations, you re-register the same device on every cold start and your backend's device tables grow forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token callbacks outlive their context.&lt;/strong&gt; A token refresh that fired pre-logout can land post-logout, and re-bind the device to the previous user. The cheap fix: hold a &lt;code&gt;lastRegisteredToken&lt;/code&gt; ref and short-circuit any callback whose token matches what you just sent.&lt;/p&gt;

&lt;p&gt;That's three places one line of pseudocode is hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions are three different things
&lt;/h2&gt;

&lt;p&gt;iOS, Android pre-13, Android 13+. Each one is a different model, and you handle all three in one codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS&lt;/strong&gt; is the cleanest. Request &lt;code&gt;Alert | Sound | Badge&lt;/code&gt; once, the OS gives you a yes/no. There are extras most apps never use: provisional authorization (notifications go straight to the notification center), critical alerts (bypass Do Not Disturb), time-sensitive alerts on iOS 15+. The core flow is one async call. Catch: if the user denies, you can never re-prompt. They go to Settings or nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android pre-13&lt;/strong&gt; has no runtime permission for notifications at all. As long as the user installed your app, you can post. &lt;em&gt;But&lt;/em&gt; on Android 8+ you have to create a notification channel first or the notification is silently dropped. The channel is also where importance, sound, and vibration live, not the notification itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;ensureChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&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="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SDK_INT&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;O&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;channel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NotificationChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nc"&gt;CHANNEL_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;"General"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nc"&gt;NotificationManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IMPORTANCE_HIGH&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"App notifications"&lt;/span&gt;
      &lt;span class="nf"&gt;enableVibration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;manager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSystemService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NOTIFICATION_SERVICE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;NotificationManager&lt;/span&gt;
    &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createNotificationChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IMPORTANCE_HIGH&lt;/code&gt; is what enables heads-up banners. &lt;code&gt;IMPORTANCE_DEFAULT&lt;/code&gt; puts the notification in the tray with no popup. The user can override your importance from settings, and you have no recourse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android 13+&lt;/strong&gt; added &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; as a runtime permission. You declare it in the manifest, request it at runtime, &lt;em&gt;and&lt;/em&gt; check the SDK level before requesting because the API doesn't exist below 33. Like iOS, a denied permission has to be re-granted from system settings.&lt;/p&gt;

&lt;p&gt;The branch you actually write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SDK_INT&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TIRAMISU&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="nc"&gt;ContextCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkSelfPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;POST_NOTIFICATIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;GRANTED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ActivityCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;arrayOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;POST_NOTIFICATIONS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;ensureChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three platforms, three permission models, one codebase. None of this is in the tutorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three app states, three completely different code paths
&lt;/h2&gt;

&lt;p&gt;This is the centerpiece of the iceberg. The same notification has to be handled three ways, depending on what the app is doing when it arrives.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;iOS callback&lt;/th&gt;
&lt;th&gt;Android callback&lt;/th&gt;
&lt;th&gt;OS shows banner?&lt;/th&gt;
&lt;th&gt;App can react before tap?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Foreground&lt;/td&gt;
&lt;td&gt;&lt;code&gt;willPresentNotification:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;onMessageReceived&lt;/code&gt; (FCM service)&lt;/td&gt;
&lt;td&gt;No (you choose)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;didReceiveNotificationResponse:&lt;/code&gt; (on tap)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;onMessageReceived&lt;/code&gt; + tap intent&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quit / killed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;launchOptions[...RemoteNotificationKey]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intent extra in &lt;code&gt;MainActivity&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each row hides something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Foreground.&lt;/strong&gt; iOS does &lt;em&gt;not&lt;/em&gt; show a system banner when the app is open. You decide. The decision is one option mask returned from &lt;code&gt;willPresentNotification:&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;userNotificationCenter&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;UNUserNotificationCenter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;center&lt;/span&gt;
       &lt;span class="nf"&gt;willPresentNotification&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;UNNotification&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;notification&lt;/span&gt;
         &lt;span class="nf"&gt;withCompletionHandler&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;UNNotificationPresentationOptions&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nv"&gt;handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;NSDictionary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;aps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&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;userInfo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;@"aps"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="n"&gt;BOOL&lt;/span&gt; &lt;span class="n"&gt;silent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;@"content-available"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nf"&gt;intValue&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;@"alert"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;silent&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;UNNotificationPresentationOptionNone&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UNNotificationPresentationOptionAlert&lt;/span&gt;
        &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;UNNotificationPresentationOptionSound&lt;/span&gt;
        &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;UNNotificationPresentationOptionBadge&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;Skip this delegate method and foreground notifications vanish. You'll spend a week wondering why testing-on-device fails to reproduce what users are seeing.&lt;/p&gt;

&lt;p&gt;Android has no equivalent. The FCM service runs on every notification regardless of state, so you detect foreground yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;info&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ActivityManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RunningAppProcessInfo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nc"&gt;ActivityManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMyMemoryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isForeground&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;importance&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;IMPORTANCE_FOREGROUND&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Foreground: suppress the system notification, let JS render its own banner. Background: build the system notification and post it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background.&lt;/strong&gt; The OS shows the banner. Your code runs only when the user taps. iOS hands you the payload via &lt;code&gt;didReceiveNotificationResponse:&lt;/code&gt;. Android hands you the intent in &lt;code&gt;MainActivity.onCreate&lt;/code&gt; or &lt;code&gt;onNewIntent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quit.&lt;/strong&gt; The app process is dead. The OS shows the banner. On tap, the app cold-starts, and your notification handler doesn't exist yet. JavaScript hasn't been loaded. The payload is delivered as a launch option (iOS) or an intent extra (Android), and you have to retrieve it &lt;em&gt;after&lt;/em&gt; React mounts:&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;initial&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;getInitialNotification&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;initial&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setRedirectTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bootingApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android, the cleanest move is to write the payload to SharedPreferences in the FCM service when the app isn't running, then read it back when JS comes online. Skip this and the launch intent gets consumed by the OS before your code is alive to see it.&lt;/p&gt;

&lt;p&gt;The mistake I made first was writing one handler. They look like the same event from JS. They are not. The cold-start payload on iOS is shaped differently from the foreground one. The Android intent doesn't look like the FCM message your foreground handler receives. We'll get to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race nobody warns you about
&lt;/h2&gt;

&lt;p&gt;A user taps a notification. The app was killed. The OS hands you the payload before:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React has mounted&lt;/li&gt;
&lt;li&gt;Your navigation container is ready&lt;/li&gt;
&lt;li&gt;Auth state has hydrated from storage&lt;/li&gt;
&lt;li&gt;The initial data fetch has resolved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Call &lt;code&gt;navigation.navigate(...)&lt;/code&gt; here and three things can happen, all bad. You crash. You no-op silently and the user is stuck on the splash screen. Or you navigate, but auth is empty. The screen renders unauthenticated and bounces them to login. Confusing, since they came in through a notification that implied they were already signed in.&lt;/p&gt;

&lt;p&gt;The fix is a "deferred redirect" pattern. Notifications never navigate directly. They write a target into context state. A separate effect watches both the target and a &lt;code&gt;readyToRedirect&lt;/code&gt; flag, and only fires when every precondition holds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;readyToRedirect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;isLoggedIn&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;isFirstLogin&lt;/span&gt;
     &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hasSeenOnboarding&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;isHydrating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLoggedIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFirstLogin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hasSeenOnboarding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isHydrating&lt;/span&gt;&lt;span class="p"&gt;]&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;readyToRedirect&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;redirectTarget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;navigationRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;CommonActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;index&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="na"&gt;routes&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;Home&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;Detail&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;redirectTarget&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;setRedirectTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;readyToRedirect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redirectTarget&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern handles a more annoying case: the user is in the foreground, a notification comes in, they tap the in-app banner, but their session expired between the banner appearing and the tap. The redirect waits in state until auth re-hydrates, then fires. The tap doesn't get lost.&lt;/p&gt;

&lt;p&gt;The corollary is about &lt;em&gt;when&lt;/em&gt; you register listeners. Don't register on app startup. Register only when &lt;code&gt;appStateVisible === 'active' &amp;amp;&amp;amp; isLoggedIn&lt;/code&gt;. Otherwise the very first launch (fresh install, logged out, no auth state) fires a redirect to a screen the user can't open, and the navigator throws.&lt;/p&gt;

&lt;p&gt;A "wait until ready" gate sounds trivial until you count how many things have to be ready. In production I count four: auth, navigation, hydrated storage, first paint. Miss any one and the bug only reproduces on cold starts triggered by notifications. Every notification.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payload is not the payload
&lt;/h2&gt;

&lt;p&gt;A notification on iOS arrives in three or four shapes depending on app state.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Foreground&lt;/strong&gt;: top-level &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, custom data on &lt;code&gt;customData&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start&lt;/strong&gt; (from &lt;code&gt;launchOptions&lt;/code&gt;): &lt;code&gt;aps.alert.title&lt;/code&gt;, &lt;code&gt;aps.alert.body&lt;/code&gt;, custom data still on &lt;code&gt;customData&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent push&lt;/strong&gt;: &lt;code&gt;aps.content-available: 1&lt;/code&gt;, no alert, custom data only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Android adds another twist. FCM only allows string-string maps in the &lt;code&gt;data&lt;/code&gt; field, so any structured custom data is &lt;strong&gt;JSON-stringified&lt;/strong&gt; by the sender. You parse it on the JS side, in a try/catch, with a logged warning on failure. Skip the try/catch and a single malformed payload from the backend silently breaks your entire notification handler.&lt;/p&gt;

&lt;p&gt;The only sane move is a normalizer. One &lt;code&gt;parseNotification&lt;/code&gt; that returns a single shape, and the rest of the app only ever sees that shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ParsedNotification&lt;/span&gt; &lt;span class="o"&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;redirect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;silent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broadcast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;targetId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RawNotification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ParseOpts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ParsedNotification&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;safeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;return&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;targetId&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&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;url&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bootingApp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;targetId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="na"&gt;targetId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests cover each variant: cold-start iOS, foreground iOS, Android with valid JSON, Android with malformed JSON. The malformed-JSON test is the one that catches the bug. Every time the backend ships a payload change without telling mobile, the test fails before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bits you'll only learn by shipping
&lt;/h2&gt;

&lt;p&gt;Things that aren't in any tutorial. I learned each of these from a bug report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notification Service Extensions have a 30-second budget.&lt;/strong&gt; If you're modifying notifications in a Service Extension (decryption, downloading rich media, sending delivery receipts), the OS kills the extension and delivers the original payload if you overshoot. Set a 5-second timeout on any HTTP work the extension does. The user gets the notification either way, but you don't want a silently-truncated payload because the network was slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App icon badges are yours to manage.&lt;/strong&gt; The OS doesn't decrement them when the user reads a notification. You set the count from the app, from the server, or both, and reset to zero when the relevant view mounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delivery receipts are a fourth code path.&lt;/strong&gt; If you want to know which notifications were actually delivered, you wire receipts in three places: the Service Extension fires "received" before the user sees the banner, the foreground/tap handler fires "read on tap," and silent push fires "received" again. None of these deduplicate. The backend has to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token refresh fires while you're logged out.&lt;/strong&gt; FCM rotates on its own schedule. If your registration code doesn't check whether anyone's logged in, you'll re-bind the device to a stale or wrong user. Guard with a stored &lt;code&gt;lastRegisteredFor&lt;/code&gt; and a "user is currently logged in" check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct boot.&lt;/strong&gt; If you want notifications to surface while the device is encrypted-and-locked (post-reboot, pre-unlock), your FCM service needs &lt;code&gt;directBootAware="true"&lt;/code&gt; in the manifest, and you can only access protected storage. Most apps don't need this. The ones that do really need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Foreground listener leaks.&lt;/strong&gt; Register on resume, unregister on background. Otherwise context updates fire after the relevant component unmounts, and you log a &lt;code&gt;setState on unmounted component&lt;/code&gt; warning every time a notification arrives. The user sees nothing wrong. Your error logs fill up.&lt;/p&gt;

&lt;p&gt;None of these are in the README. They become the README, after.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got back
&lt;/h2&gt;

&lt;p&gt;What I have now is a push notification system that survives every state combination I've actually seen: cold start, mid-login, kill-and-relaunch, OS reboot, denied-then-granted permissions, foreground notification while a session is timing out. The path the user actually takes is rarely the one in the demo video.&lt;/p&gt;

&lt;p&gt;A few concrete things came out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;parseNotification&lt;/code&gt; normalizer with tests covering four payload shapes, runnable in plain Node, no native bridges required&lt;/li&gt;
&lt;li&gt;A clean seam: native owns registration, foreground display, OS plumbing, intent retrieval. JS owns parsing, navigation, product logic. They don't drift because they speak through one event shape.&lt;/li&gt;
&lt;li&gt;A deferred-redirect pattern that handles cold-start, foreground, and re-auth races with one piece of code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the cost. Every iOS major version, every Android API bump, I have to revisit this. The libraries would have absorbed some of it. I chose not to use them because the failure modes I cared about (iOS 18 registration, cold-start payload divergence, silent push receipts, the navigation race) were already filed against those libraries, unfixed. That's the trade I made. I'd make it again, knowing the maintenance cost up front.&lt;/p&gt;

&lt;p&gt;Push notifications were a one-line item on the roadmap. The line item was true. It just wasn't the whole shape.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>The loop that changed how I write mobile tests</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Sun, 03 May 2026 03:59:22 +0000</pubDate>
      <link>https://dev.to/aoligama/the-loop-that-changed-how-i-write-mobile-tests-3mf3</link>
      <guid>https://dev.to/aoligama/the-loop-that-changed-how-i-write-mobile-tests-3mf3</guid>
      <description>&lt;p&gt;Mobile tests are where the bugs actually live. A signup flow that works on an iPhone 15 falls apart on a lower-end Android because the keyboard pushes a button off-screen. A push notification mid-flow leaves the app in a state nothing else reproduces. Memory pressure on a four-year-old Android does things you can't make a simulator do.&lt;/p&gt;

&lt;p&gt;I wrote simulator-only tests anyway, for years. Real-device runs took ten to thirty minutes per cycle, the device farm queue was unpredictable, and CI on physical hardware was expensive enough that someone always wanted to talk about cost. So tests got written for the simulator, the device-specific bugs found their way to production, and we caught them in Sentry instead of in CI.&lt;/p&gt;

&lt;p&gt;The thing that changed wasn't a better device farm or a faster CI. It was Claude Code writing the tests, BrowserStack MCP driving the devices, and the whole loop closing inside a single session. Claude writes a test, BrowserStack runs it on an actual iPhone, Claude reads the failure (screenshot, view tree, error trace) and fixes the test. Then it runs again. The cycle is under a minute. I never opened the test file.&lt;/p&gt;

&lt;p&gt;That's the post. The tools matter, but what changed test authoring was the loop closing fast enough that I could stay in it.&lt;/p&gt;

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

&lt;p&gt;The mental model is simple. Old loop: write a test, push, wait for CI, read logs, guess at the failure, fix, push again. Best case ten minutes per iteration, often half an hour. Most of the wait is travel time: uploading the build, queuing for a device, tearing down. Almost none of it is the test itself.&lt;/p&gt;

&lt;p&gt;New loop: Claude writes a test, the BrowserStack MCP runs it on a real device that's already provisioned, Claude reads the structured failure (screenshot, accessibility tree, console output, error stack), edits the test, runs it again. End-to-end under a minute on the happy path.&lt;/p&gt;

&lt;p&gt;The speed isn't the value. The value is that the loop stays tight enough to stay in. Ten-minute iterations mean you context-switch out and come back cold. Sub-minute iterations mean the same problem is still in your head when the next result lands. You think about the test, not about what you were doing while you waited for CI.&lt;/p&gt;

&lt;p&gt;The MCP is mostly invisible in this. It's just "Claude can drive real devices the way it drives anything else."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the loop actually looks like
&lt;/h2&gt;

&lt;p&gt;The clearest example was when I asked Claude to build an onboarding flow test from scratch: fresh install through to the home screen, with sign-up, email verification, and a multi-step intro along the way.&lt;/p&gt;

&lt;p&gt;I described it in plain English. Claude wrote the first version: a script with the steps, selectors pulled from the screenshot it took on app launch, and an assertion at each milestone. It started a session on an iPhone 15, ran the test, and the test failed at step two.&lt;/p&gt;

&lt;p&gt;The failure was a selector ambiguity. There were two elements matching &lt;code&gt;[name="email"]&lt;/code&gt;: the visible field and a hidden form used for autofill detection. Claude saw both in the accessibility tree, picked the wrong one, and the typed text vanished into the hidden one. The test waited for the "verify your email" screen and timed out.&lt;/p&gt;

&lt;p&gt;Claude fixed the selector to &lt;code&gt;getByRole('textbox', { name: 'Email address' })&lt;/code&gt; and re-ran. Past step two. Failed at step four. The third onboarding screen has an animated transition, and the tap on the next button fired before the animation finished, so the tap landed on a different element underneath. Claude added a wait on the transition end. Past step four. Eventually green.&lt;/p&gt;

&lt;p&gt;Then I asked it to run the same test on a lower-end Android. Same code, different device. Failed on step three. The email verification deep link opens differently on Android. The Gmail app intercepts before the browser does, and the test was driving Chrome. Claude rewrote the verification step to use an alternative verification strategy for tests instead of clicking the link in the email. Re-ran on both devices. Green on both.&lt;/p&gt;

&lt;p&gt;This whole sequence took maybe fifteen minutes. I didn't open a test file. I read every diff in the chat, said "yes" or "wait, why" three or four times, and the test ended up in the repo. The thing that made it work wasn't any single capability. It was that I could stay in the loop the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where my role moved
&lt;/h2&gt;

&lt;p&gt;I wasn't writing test code. I was reading it.&lt;/p&gt;

&lt;p&gt;The most useful thing I did was catch the moments where Claude "fixed" a test by making it less strict. The pattern I learned to watch for: a test starts failing intermittently, Claude adds a wait longer than the original timeout, the test goes green. The wait isn't fixing flakiness. The wait is hiding it.&lt;/p&gt;

&lt;p&gt;The cleanest example was a test that asserted the home screen rendered within 200ms after login. It started failing on the lower-end Android. Claude bumped the wait to ten seconds, the test passed, and we moved on. I caught it in review: the home screen was actually taking five seconds to render on that device because of an image-decode regression that had landed two days earlier. The test was supposed to catch exactly that. The "fix" deleted the catch.&lt;/p&gt;

&lt;p&gt;This is the part the "LLM writes the tests" pitch always undersells. Claude is fast enough at authoring that it'll write a hundred tests in the time it would take me to write ten. Most of those tests are fine. Plenty pass because they assert close to nothing. If you don't read them, you ship a green suite that catches nothing, which is worse than no suite at all.&lt;/p&gt;

&lt;p&gt;The bottleneck moved from authoring to judgment. Authoring is the part Claude is fast at. Judgment (does this test catch the right thing, is this assertion meaningful, is this wait masking a regression) is the part that's still mine. The work didn't disappear. It changed shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP setup is where the time went
&lt;/h2&gt;

&lt;p&gt;The loop only works when the MCP layer is reliable. When it isn't, you spend the day debugging the harness instead of writing tests.&lt;/p&gt;

&lt;p&gt;The first day was almost entirely setup. BrowserStack credentials, MCP server config, tool permissions in Claude Code, the allowed device pool, network rules for the test backend. Each piece is documented somewhere. None of them are documented in the same place. I had four tabs open the whole time.&lt;/p&gt;

&lt;p&gt;The tool surface is the bigger thing to plan for. The BrowserStack MCP exposes a finite set of actions: start session, run command, take screenshot, fetch logs. Most of what you want maps onto that cleanly. A few things don't. Driving a native permission dialog on iOS isn't a clean MCP action; you end up wrapping platform-specific helpers or carving a backdoor into the app for tests. Anything biometric is off the table without that backdoor.&lt;/p&gt;

&lt;p&gt;Session lifetime caught me twice. BrowserStack sessions time out after a fixed window, devices disconnect under load, and the queue backs up during US business hours. The loop has to survive a session dying mid-test. I added a thin wrapper that re-acquires the device when the MCP returns a session-ended error and retries from the last clean assertion. That wrapper isn't optional. Without it, every long test run had a 30% chance of dying for no reason related to the code under test.&lt;/p&gt;

&lt;p&gt;Permissions in Claude Code itself were the smaller surprise. By default, Claude asks before each tool call. The fiftieth time it asks to call the same BrowserStack action is the moment you regret it. I set a permission rule allowing that specific tool without prompts, and the loop got noticeably tighter. Skip this and you spend the day clicking "yes."&lt;/p&gt;

&lt;p&gt;None of that is the loop changing test authoring. It's the boring tax for getting there. But it's a real day or two of setup, and skipping it in this post would be misleading. The pitch is "Claude writes the tests"; the reality is "Claude writes the tests after you spend a day making the harness work."&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;Coverage we wouldn't have written by hand. The cost of writing a test dropped enough that the question stopped being "is this worth a test" and started being "is this worth reading." We added tests for flows we'd been ignoring for years (the edge paths, the rare-but-real failure modes) because the marginal cost was small enough not to argue about.&lt;/p&gt;

&lt;p&gt;Real-device coverage in CI, instead of simulator-only with prayers. The lower-end Android catches the things the iPhone 15 doesn't. The 200ms-versus-five-second image regression I mentioned earlier would have shipped if the test had stayed on a simulator.&lt;/p&gt;

&lt;p&gt;The hidden cost is the one I keep coming back to: you have to read what Claude writes. If you don't, you accumulate tests that pass without testing anything, and the suite stops being a signal. The discipline shifts from authoring to reviewing. Most teams assume whoever writes the test is the one reviewing it. That works when writing is the slow step. When writing is fast and reviewing is the slow step, the assumption breaks. I haven't seen many teams notice yet.&lt;/p&gt;

&lt;p&gt;It's not autonomous. The day it is, the discipline that survives won't be writing tests. It'll be deciding which ones still mean something.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ios</category>
      <category>mobile</category>
      <category>testing</category>
    </item>
    <item>
      <title>Migrating a legacy React app from webpack to Vite</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Sun, 03 May 2026 02:57:49 +0000</pubDate>
      <link>https://dev.to/aoligama/migrating-a-legacy-react-app-from-webpack-to-vite-9kl</link>
      <guid>https://dev.to/aoligama/migrating-a-legacy-react-app-from-webpack-to-vite-9kl</guid>
      <description>&lt;p&gt;The codebase was old. React 16 with class components everywhere. React Router v3 with routes-as-children. A webpack 4 config that had been edited by a dozen people over five years and contained loaders nobody could explain. The dev server took 45 seconds to come up. Hot reload was 8 seconds on a good day, 20 on a bad one. The production build was 6 minutes. CI deploys took 14 minutes end to end.&lt;/p&gt;

&lt;p&gt;I'd been dismissing Vite for two years. "Webpack works." It did work. It was also bleeding an hour a day off the team in dev server restarts, slow HMR cycles, and CI queues piling up behind each other.&lt;/p&gt;

&lt;p&gt;Here's the catch: you don't migrate a legacy webpack project to Vite on its own. You end up migrating webpack, React, React Router, your test runner, and half your dependencies, because the things that make Vite fast are the same things that won't tolerate code from 2018. This post is about what that actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vite actually does differently
&lt;/h2&gt;

&lt;p&gt;The mental model shift is small, but it changes everything that follows.&lt;/p&gt;

&lt;p&gt;Webpack bundles your app before serving it. Every dev server start walks the dependency graph, compiles every module it touches, links them, and produces a bundle. The bigger your app, the longer the wait, and it scales linearly. HMR is fast in theory, but in practice the graph invalidation is heavy enough that a 200ms code change turns into a 4-second rebuild.&lt;/p&gt;

&lt;p&gt;Vite serves source files directly to the browser as native ES modules. Your &lt;code&gt;App.tsx&lt;/code&gt; is requested by the browser, transformed on demand by esbuild, and sent back. Nothing gets bundled in dev. The cost of starting the server is the cost of starting a Node process plus reading config, not the cost of compiling your app.&lt;/p&gt;

&lt;p&gt;For production, Vite uses Rollup. Bundling still happens, just not in the dev loop where it hurts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;webpack dev:    parse all -&amp;gt; compile all -&amp;gt; bundle -&amp;gt; serve
vite dev:       start server -&amp;gt; compile what the browser asks for
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole insight. Everything else is a consequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The dev server times told the story I expected:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;webpack&lt;/th&gt;
&lt;th&gt;Vite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;42s&lt;/td&gt;
&lt;td&gt;1.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HMR (small change)&lt;/td&gt;
&lt;td&gt;4-8s&lt;/td&gt;
&lt;td&gt;50-200ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full reload&lt;/td&gt;
&lt;td&gt;6-9s&lt;/td&gt;
&lt;td&gt;400ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The build times were less dramatic but still real:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;webpack&lt;/th&gt;
&lt;th&gt;Vite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Production build&lt;/td&gt;
&lt;td&gt;5m 40s&lt;/td&gt;
&lt;td&gt;1m 50s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type check (separate)&lt;/td&gt;
&lt;td&gt;45s&lt;/td&gt;
&lt;td&gt;45s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The interesting part is type checking. Vite doesn't type-check your code. It strips types with esbuild and trusts you. If you want type safety in CI, you run &lt;code&gt;tsc --noEmit&lt;/code&gt; separately. That stays exactly as slow as it was. So don't expect Vite to magically speed up TypeScript. It makes the &lt;em&gt;transformation&lt;/em&gt; faster, not the checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI actually looked like before and after
&lt;/h2&gt;

&lt;p&gt;The CI deploy was the win I didn't expect. Total deploy time went from 14 minutes to 5. The build itself only accounted for about 4 minutes of that improvement. The other 5 minutes came from second-order effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smaller Docker layer.&lt;/strong&gt; Rollup tree-shakes more aggressively than my webpack config ever did. Bundle size dropped 18%, and the image pushed faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No more cache thrashing.&lt;/strong&gt; Webpack's persistent cache was 800MB, and CI was spending 90 seconds restoring it every run. Vite doesn't need one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel-friendly.&lt;/strong&gt; Type check, lint, and build can run as three independent jobs. With webpack, the build dominated everything else, so splitting was pointless.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CI cost dropped about in line with wall-clock time. Not surprising, but watching the bill go down felt good.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration itself
&lt;/h2&gt;

&lt;p&gt;The webpack-to-Vite swap on its own is a two-day job. On a legacy codebase, you don't get that luxury. The whole thing took closer to three weeks once you count the React upgrade, the router rewrite, and the test runner change that came along with it.&lt;/p&gt;

&lt;p&gt;Start with the easy part: the build tool config.&lt;/p&gt;

&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; vite @vitejs/plugin-react
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A minimal &lt;code&gt;vite.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&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;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&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;@vitejs/plugin-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&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;path&lt;/span&gt;&lt;span class="dl"&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="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
  &lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;alias&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;@&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sourcemap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;index.html&lt;/code&gt; moves to the project root and becomes the entry point, not a template anymore. That's a real shift. Webpack treated HTML as an output. Vite treats it as the input. Your script tag points at your TS entry directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/src/main.tsx"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once that clicks, half the config changes make sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came along for the ride
&lt;/h2&gt;

&lt;p&gt;This is the part the tutorials skip. Vite assumes a modern stack. A legacy app doesn't have one, and that gap is where most of the real work hides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React itself.&lt;/strong&gt; React 16 will technically run under Vite, but &lt;code&gt;@vitejs/plugin-react&lt;/code&gt; expects the new JSX transform that landed in React 17. You can pin an older plugin and limp along, or take the upgrade. I took the upgrade. Going to React 18 was straightforward once the build was clean, and Strict Mode caught a handful of effect bugs that had been hiding for years. Class components keep working. They just look increasingly out of place next to everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React Router.&lt;/strong&gt; This was the biggest single chunk of work. v3's API (routes-as-children, &lt;code&gt;browserHistory&lt;/code&gt; as a singleton, &lt;code&gt;onEnter&lt;/code&gt; hooks for auth) is just not compatible with v6's declarative &lt;code&gt;&amp;lt;Routes&amp;gt;&lt;/code&gt; and &lt;code&gt;useNavigate&lt;/code&gt;. There's no codemod that does it cleanly. I migrated in stages:&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="c1"&gt;// v3&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Router&lt;/span&gt; &lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;browserHistory&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;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;App&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;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"users/:id"&lt;/span&gt; &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;UserPage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onEnter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;requireAuth&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;Route&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;Router&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// v6&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BrowserRouter&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;Routes&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;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;App&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"users/:id"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;RequireAuth&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserPage&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;RequireAuth&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Route&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;Routes&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;BrowserRouter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanical translation is easy. The painful part is everywhere the old code reached into the router imperatively: &lt;code&gt;browserHistory.push()&lt;/code&gt; from a saga, &lt;code&gt;withRouter&lt;/code&gt; HOCs wrapping class components, route lifecycle hooks doing data fetching. Each one needs a real rewrite.&lt;/p&gt;

&lt;p&gt;Budget honestly for this. The router upgrade took longer than the actual Vite swap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Babel plugins you forgot you had.&lt;/strong&gt; The old webpack config was running half a dozen Babel plugins for proposal-stage syntax that's been in the language for years: optional chaining, nullish coalescing, class properties. esbuild handles all of those natively, so you can delete them. But if any plugin was doing real work (a custom transform, an i18n extractor, an instrumentation pass), that has to become a Vite plugin or move to a separate step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dependency graveyard.&lt;/strong&gt; Every legacy project has one. A charting library that hasn't published since 2019. A date picker pinned to a specific patch version. A state library the company forked internally. Anything shipping pure CommonJS without an &lt;code&gt;exports&lt;/code&gt; field is going to fight Vite's dep optimizer. Some you can pre-bundle. Some need replacing. A few you'll end up patching with &lt;code&gt;patch-package&lt;/code&gt; because the maintainer is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vite-specific gotchas
&lt;/h2&gt;

&lt;p&gt;Beyond the legacy upgrade work, these are the traps that hit any webpack-to-Vite migration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment variables.&lt;/strong&gt; &lt;code&gt;process.env.FOO&lt;/code&gt; becomes &lt;code&gt;import.meta.env.VITE_FOO&lt;/code&gt;, and only variables prefixed with &lt;code&gt;VITE_&lt;/code&gt; are exposed to the client. That's a security feature (webpack would happily leak any env var you referenced), but it broke about 40 references in my codebase. Find-and-replace is your friend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic imports with variable paths.&lt;/strong&gt; This works in webpack and breaks in Vite:&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;mod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`./locales/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vite needs to know the set of possible matches at build time. Use &lt;code&gt;import.meta.glob&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modules&lt;/span&gt; &lt;span class="o"&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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./locales/*.json&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;mod&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;modules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`./locales/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&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;CommonJS dependencies.&lt;/strong&gt; Vite is ESM-first. Most modern packages are fine. Older ones with &lt;code&gt;module.exports&lt;/code&gt; need &lt;code&gt;optimizeDeps.include&lt;/code&gt; to pre-bundle them. Otherwise you get cryptic "default export" errors at runtime, and they only show up in production, because dev doesn't bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jest doesn't speak Vite.&lt;/strong&gt; If you used &lt;code&gt;jest&lt;/code&gt; with &lt;code&gt;babel-jest&lt;/code&gt; configured to match webpack, your test setup is now misaligned with your build. The cleanest path is Vitest, which shares Vite's transform pipeline. The migration is mostly mechanical (&lt;code&gt;describe&lt;/code&gt;, &lt;code&gt;it&lt;/code&gt;, &lt;code&gt;expect&lt;/code&gt; are the same), but the mocking syntax differs from Jest in spots. Budget half a day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public path / base URL.&lt;/strong&gt; If your app is served from a subpath, webpack's &lt;code&gt;publicPath&lt;/code&gt; becomes Vite's &lt;code&gt;base&lt;/code&gt;. Forget this and you get a working dev build and a broken production deploy. I learned that one the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't get better
&lt;/h2&gt;

&lt;p&gt;Bundle analysis is worse. Webpack's analyzer ecosystem is mature. Vite's is fine, but less detailed. If you spend real time tuning bundle size, you'll feel the gap.&lt;/p&gt;

&lt;p&gt;The plugin ecosystem is smaller. Most things you need exist, but expect to occasionally write a Rollup plugin where webpack would've had three options.&lt;/p&gt;

&lt;p&gt;SSR setup is more hands-on. Vite supports it, but you wire it together yourself. If you were running a framework on top of webpack that handled SSR for you, switch to a framework on top of Vite (Remix, Next, SvelteKit) instead of doing it raw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;For a greenfield project, Vite is a no-brainer. For a legacy app, it's messier. You're not just swapping a build tool. You're paying down years of tech debt because Vite forces you to. That's a feature, not a bug. Just budget for it honestly.&lt;/p&gt;

&lt;p&gt;The dev experience win paid for itself the first week the team was on the new stack. After years of training your muscle memory to tolerate slow HMR, watching it feel instant is a small daily joy.&lt;/p&gt;

&lt;p&gt;The deploy time win mattered more than I expected. Halving CI meant we merged to main more often, which meant smaller PRs and faster review cycles. That compound effect over a quarter was bigger than the raw numbers suggest.&lt;/p&gt;

&lt;p&gt;The hidden win was the dependency cleanup, and I didn't see it coming. The migration forced a real audit of every package in &lt;code&gt;package.json&lt;/code&gt;. A third of them were dead code or had modern replacements. The codebase that came out the other side was lighter and easier to onboard onto, and that part had nothing to do with Vite directly.&lt;/p&gt;

&lt;p&gt;If you're on legacy webpack and your dev server takes more than 20 seconds to start, migrate. Just don't pitch it to your manager as a two-day job. It's a quarter of modernization work with a fast build tool at the end of it, and the payoff keeps showing up long after you're done.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>react</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Working remotely, the parts that actually mattered</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Sun, 03 May 2026 02:51:28 +0000</pubDate>
      <link>https://dev.to/aoligama/working-remotely-the-parts-that-actually-mattered-299o</link>
      <guid>https://dev.to/aoligama/working-remotely-the-parts-that-actually-mattered-299o</guid>
      <description>&lt;p&gt;I've been working remotely for a while, and most of what I picked up in the first six months turned out to be wrong, or wildly overrated. Not bad advice exactly. Most of it sounds reasonable when you read it. It just isn't doing the work it claimed to. The "wake up at 5am, dedicate a workspace, use the Pomodoro technique, journal every morning" stack is a kind of theater. Some of it helps a little. Most of it is energy you spend trying to feel productive instead of being productive.&lt;/p&gt;

&lt;p&gt;What actually helped was less visible. Almost all of it was about removing small frictions, not adding discipline. The good weeks came from making fewer parts of the day cost mental energy. Not from squeezing more output out of willpower.&lt;/p&gt;

&lt;p&gt;Here's what's stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your setup matters, but most of it doesn't
&lt;/h2&gt;

&lt;p&gt;The setup advice you find online tends to confuse two different things: gear that looks good on your desk and gear that actually changes how you work. The list that genuinely helped me is short and boring.&lt;/p&gt;

&lt;p&gt;A second monitor. This is the only piece of "setup" I'd call non-negotiable. Doing most knowledge work on a single laptop screen is the equivalent of writing in a notebook with the previous page glued shut.&lt;/p&gt;

&lt;p&gt;A chair you can sit in for six hours without your back giving notice. You don't need the $1,500 ergonomic one. You need one that doesn't make you fidget by 3pm.&lt;/p&gt;

&lt;p&gt;A decent headset. Not for the audio quality, though that helps. For the "I'm wearing headphones" signal that lets you ignore the world without feeling rude. The mic built into most laptops is fine.&lt;/p&gt;

&lt;p&gt;Enough light. Especially in winter, especially for video calls. A cheap overhead light plus something at face level is the floor.&lt;/p&gt;

&lt;p&gt;Things that didn't change anything for me: a mechanical keyboard, a fancy webcam, a standing desk, a third monitor, cable management trays, a "cozy" lamp, a houseplant. Some of those are nice. None of them changed how much I got done. If you're spending more than a weekend setting up your workspace, you're probably procrastinating with extra steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routines beat motivation, but lighter than you'd think
&lt;/h2&gt;

&lt;p&gt;The hardcore routine content (wake at 5am, cold shower, journal, gym, deep work block before email) is impressive on Instagram and unsustainable in real life. I tried versions of it. The version that survived was much smaller.&lt;/p&gt;

&lt;p&gt;Two rituals, one rule.&lt;/p&gt;

&lt;p&gt;The first ritual is a fixed start. Same first action every morning, regardless of what's on the calendar. For me it's coffee plus ten minutes reviewing what I want to get done before lunch. Not the whole day. Just the morning. The point isn't planning. It's the transition from "not working" to "working," so you don't burn the first hour ramping up.&lt;/p&gt;

&lt;p&gt;The second is a fixed end. Before closing the laptop, write down the first task you'll start tomorrow. One sentence, on paper or in a note. This sounds stupid until you realize how much energy you waste at the start of every day re-deriving where you left off.&lt;/p&gt;

&lt;p&gt;The rule is: don't open Slack first thing. Anything genuinely urgent will still be urgent in 90 minutes. The first block of the day is the only one where you reliably have full focus, and burning it on reactive replies is the single biggest productivity leak I had to fix.&lt;/p&gt;

&lt;p&gt;What I gave up: time-blocking the whole day, color-coded calendars, hourly check-ins with myself, gratitude journals. Not because they're bad, but because for me they were maintenance overhead disguised as productivity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Communication is half the job
&lt;/h2&gt;

&lt;p&gt;The thing nobody really warns you about is how much of remote knowledge work is writing. Slack messages, PR comments, design docs, status updates, weekly summaries. If you can't write reasonably clearly, remote work is going to be harder than it was in the office, where tone and presence covered a lot of gaps.&lt;/p&gt;

&lt;p&gt;A few things I've changed:&lt;/p&gt;

&lt;p&gt;I default to written updates over meetings. The standing weekly check-in I used to do is now a five-bullet update I write on Friday afternoons. Read in two minutes, no scheduling cost, searchable for later. The meetings I keep are the ones that need actual discussion. Not status, not "alignment," not "syncing."&lt;/p&gt;

&lt;p&gt;I'm slow to reply on purpose. If you answer every Slack within ten minutes, you've trained the team to use you as a synchronous resource, which is the opposite of remote. An informal four-hour SLA on non-urgent threads is usually enough to let people figure things out themselves before pinging.&lt;/p&gt;

&lt;p&gt;I write outcomes, not activities. "Shipped the new auth flow" is useful. "Worked on auth all afternoon" isn't. Everyone's calendar is full of work; what people actually want to know is what came out of it.&lt;/p&gt;

&lt;p&gt;I avoid the 15-minute "quick sync." It's never quick. Either it's a question that could be answered in writing in three minutes, or it's a real conversation that deserves 30 minutes and a doc. The middle case mostly doesn't exist, and treating it like it does is how a calendar gets eaten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect deep work like it's a billable hour
&lt;/h2&gt;

&lt;p&gt;Two hours of uninterrupted focus is more valuable than eight hours of fragments. This is the most-repeated and most-ignored piece of remote-work advice. It's true, and most people (me included, until recently) treat it as aspirational rather than enforceable.&lt;/p&gt;

&lt;p&gt;Here's what actually works:&lt;/p&gt;

&lt;p&gt;Two-hour blocks on the calendar, marked busy, treated as real meetings. Not "focus time." That's too vague to defend against your own future self. Specific: "Architecture review draft: do not book over."&lt;/p&gt;

&lt;p&gt;Notifications off during those blocks. Slack quit, email closed, phone in another room. The phone-in-another-room thing sounds dramatic; it's the single change that made the biggest difference for me. Reaching for the phone to "check something" was costing me twenty minutes per occurrence and I wasn't tracking it.&lt;/p&gt;

&lt;p&gt;One context switch (a non-urgent message, a notification, a quick check of something) costs around twenty minutes of lost focus on hard work. Not because the switch is long, but because the climb back is. If you understand that and still switch, fine. Most of us don't, and the cost compounds across the day.&lt;/p&gt;

&lt;p&gt;The hardest part is letting things sit. Replies waiting, threads moving, a question someone just asked. All of it can wait until your block is done. That's the muscle.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you add travel to the mix
&lt;/h2&gt;

&lt;p&gt;I've been working remotely while traveling for stretches, and the honest version is that travel makes most of the above harder, not easier. The Instagram pitch (laptop on a beach, perfect productivity) is selectively true on a small number of days, and it costs you on most of the others.&lt;/p&gt;

&lt;p&gt;The things that get harder, in rough order of how much they hurt. Time zones first. You're either disconnected from your team for half the day, or answering Slack at midnight. Then internet. A hotspot stops being optional and becomes the actual connection in most places. Then environment. A different desk, different chair, different noise floor every week or two. The compounding effect of "decent enough" beats the effect of "novel" within about a month.&lt;/p&gt;

&lt;p&gt;What helps, if you do it anyway: pick places with infrastructure first and aesthetics second. Carry a backup hotspot on a different carrier. Don't try to start work the day you arrive somewhere new. The first 24 hours is logistics, not output. Protect at least four hours of overlap with your team, wherever you are.&lt;/p&gt;

&lt;p&gt;What you give up is real: deeper rest, a stable rhythm, a setup that gets a little better every month. Travel is fun. It's also a tax on output. If the location is the point, you'll love it. If the work is the point, you'll find that traveling less makes the work easier, which is worth knowing before you sign a six-month lease somewhere far away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I've stopped believing
&lt;/h2&gt;

&lt;p&gt;A few things I dropped along the way.&lt;/p&gt;

&lt;p&gt;That gear matters much beyond the basics. It doesn't.&lt;/p&gt;

&lt;p&gt;That every hour of the day needs to be productive. Most of mine aren't, even on good days, and trying to force them just adds anxiety to the unproductive ones.&lt;/p&gt;

&lt;p&gt;That working from "cool" places makes you happier. It makes the photos better. The day-to-day feels about the same.&lt;/p&gt;

&lt;p&gt;That async means everything has to be in writing. Some conversations need to be conversations. Trying to write your way through every disagreement, every nuanced design call, every piece of feedback is its own kind of overhead.&lt;/p&gt;

&lt;p&gt;That discipline is the answer. Most days, removing one source of friction beats adding one new habit. The version of remote work that lasted, for me, has fewer rules than the version I started with, and the ones that remain are mostly about what I don't do.&lt;/p&gt;

</description>
      <category>career</category>
      <category>devjournal</category>
      <category>discuss</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Clean architecture in React Native isn't about layers</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Fri, 01 May 2026 03:46:40 +0000</pubDate>
      <link>https://dev.to/aoligama/clean-architecture-in-react-native-isnt-about-layers-agl</link>
      <guid>https://dev.to/aoligama/clean-architecture-in-react-native-isnt-about-layers-agl</guid>
      <description>&lt;p&gt;Every React Native codebase I've worked on hits the same wall around month four. A screen that started at 80 lines is now 400. Half of it is &lt;code&gt;useEffect&lt;/code&gt; chains coordinating API calls. A push notification mid-flow leaves the app in a state nobody can reproduce.&lt;/p&gt;

&lt;p&gt;Clean architecture in React Native isn't about folders or layers. It's about whether you can still reason about your app when async, navigation, and native modules collide.&lt;/p&gt;

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

&lt;p&gt;The default React Native architecture is "everything lives where it's first needed." API calls land in the handler that triggers them. State sits in the screen that displays it. Native modules get called from the button that activates them. It works for the first month.&lt;/p&gt;

&lt;p&gt;What it doesn't survive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Async outliving its caller.&lt;/strong&gt; A user kicks off a request, taps a notification, lands on a different screen. The original promise resolves into a setter that no longer makes sense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native modules in the UI.&lt;/strong&gt; A screen calls &lt;code&gt;NativeModules.Audio.start()&lt;/code&gt; directly. iOS 17 changes the audio session semantics. Three screens break, not one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth races.&lt;/strong&gt; A token refresh fires while three other requests are in flight. Two retry, one logs the user out, one leaks the old token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift.&lt;/strong&gt; The same "send a message" logic lives in two screens. One gets a validation rule added. The other doesn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern that produces all of this:&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;handleSend&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing wrong with this code on its own. The problem is the second time it gets written, slightly differently, in another screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers, one rule
&lt;/h2&gt;

&lt;p&gt;Skip the diagram. The minimum viable framing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Presentation.&lt;/strong&gt; Screens, components, hooks. Renders. Orchestrates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain.&lt;/strong&gt; Use cases. Pure logic. No &lt;code&gt;react&lt;/code&gt;, no &lt;code&gt;fetch&lt;/code&gt;, no &lt;code&gt;NativeModules&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; API clients, storage, native bridges. Knows about the outside world.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule: &lt;strong&gt;the UI talks to the domain, never to data directly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the post. The rest is what enforcing that rule does to the bugs above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases are the boundary
&lt;/h2&gt;

&lt;p&gt;The shift in code is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: handler decides how things work&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleSend&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After: handler delegates&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleSend&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;sendMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The use case is where the logic actually lives:&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;class&lt;/span&gt; &lt;span class="nc"&gt;SendMessage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessageRepository&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="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SendMessageInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// validation, business rules, orchestration&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repo&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't ceremony. The point is that &lt;code&gt;SendMessage&lt;/code&gt; is the only place anyone outside the domain learns how a message gets sent. Two screens calling it can't drift apart, because there's only one of it.&lt;/p&gt;

&lt;p&gt;The repository is the other half:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;MessageRepository&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SendMessageInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="o"&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;The use case depends on the interface. The implementation lives in the data layer. The UI imports neither. It imports the use case, calls &lt;code&gt;execute&lt;/code&gt;, and stops thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where React Native makes you pay for shortcuts
&lt;/h2&gt;

&lt;p&gt;This is the part most "clean architecture" posts are silent on. Web apps have UI and API. React Native has UI, API, navigation lifecycle, native modules, background/foreground transitions, and OS-level interruptions. The cost of mixing layers compounds with each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async outliving the screen.&lt;/strong&gt; A request starts on screen A and resolves after the user is on screen C. If the resolution reaches for component-local setters, navigation refs, or context that no longer exists, you get a bug that only reproduces when someone moves fast. A use case gives you one place to attach cancellation, idempotency, or "is this caller still listening?" guards. The screen doesn't need to know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native modules don't belong in handlers.&lt;/strong&gt; &lt;code&gt;NativeModules.Audio.start()&lt;/code&gt; in a button handler ties the UI to platform behavior. Platform behavior is the part most likely to diverge between iOS and Android, between OS versions, between simulator and device. Wrap the module in a repository, expose a use case (&lt;code&gt;StartRecording&lt;/code&gt;), and the UI is platform-agnostic. The platform-specific logic has one home, and you know where to look when iOS changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth and rehydration races.&lt;/strong&gt; Token refresh overlapping with three in-flight requests is the canonical React Native bug. If your auth logic is split across an axios interceptor, a context provider, and a screen, the race is unfixable. There's no single thing to serialize. A &lt;code&gt;RefreshSession&lt;/code&gt; use case that owns the queue makes it tractable. Boring, but tractable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests stop pretending
&lt;/h2&gt;

&lt;p&gt;The biggest practical payoff isn't reuse. It's that tests stop needing the framework.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sends a message via the repo&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="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;repo&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;FakeMessageRepo&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;useCase&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;SendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&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;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No render tree. No &lt;code&gt;react-test-renderer&lt;/code&gt;. No mocked &lt;code&gt;NativeModules&lt;/code&gt;. No Detox. The use case runs in pure Node and exits in milliseconds.&lt;/p&gt;

&lt;p&gt;Most of the value of architecture is what becomes testable, not what becomes "clean."&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;A few ways this goes wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Optional architecture isn't architecture.&lt;/strong&gt; "I'll just call the API directly this once" is how you end up with three places that do the same thing badly. Either the boundary is enforced or it isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three layers for a two-screen app is waste.&lt;/strong&gt; If your app is a login and a list, you don't need a use case layer. Apply this when the complexity earns it, usually somewhere between the third real feature and the second engineer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Folders aren't boundaries.&lt;/strong&gt; You can have a &lt;code&gt;domain/&lt;/code&gt; directory and still call &lt;code&gt;fetch&lt;/code&gt; from a screen. The directory structure is documentation. ESLint rules and code review are enforcement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other cost is upfront friction. A new feature now touches three files instead of one. For a few weeks that feels worse, not better. It pays off the first time a bug reproduces only on Android, only after a notification, only when offline. You find the cause in one place instead of grepping six.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;Clean architecture in React Native isn't a goal, and it isn't about being clean. Something will go wrong at month twelve, in a way you didn't predict. It's the bill you pay so the code you're staring at is still one you can reason about.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>cleancode</category>
    </item>
  </channel>
</rss>
