<?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: Soham Mondal</title>
    <description>The latest articles on DEV Community by Soham Mondal (@sohammondal).</description>
    <link>https://dev.to/sohammondal</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F214163%2F70f3a675-a9a0-46d2-86ad-78fe7758ca89.jpeg</url>
      <title>DEV Community: Soham Mondal</title>
      <link>https://dev.to/sohammondal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sohammondal"/>
    <language>en</language>
    <item>
      <title>How to Secure a Staging Environment With Google Cloud IAP</title>
      <dc:creator>Soham Mondal</dc:creator>
      <pubDate>Tue, 01 Sep 2026 07:01:44 +0000</pubDate>
      <link>https://dev.to/sohammondal/how-to-secure-a-staging-environment-with-google-cloud-iap-3hp7</link>
      <guid>https://dev.to/sohammondal/how-to-secure-a-staging-environment-with-google-cloud-iap-3hp7</guid>
      <description>&lt;p&gt;Every staging environment starts out reachable by anyone with the URL, and every team eventually notices that's a problem — a crawler indexes it, a support ticket links to it in public, an ex-employee's bookmark still works. The fix people reach for first is usually one of two extremes: a VPN nobody wants to run, or a login screen nobody wants to maintain. On Google Cloud there's a narrower option that costs almost nothing to turn on: Identity-Aware Proxy (IAP), a toggle on the load balancer that requires a verified Google identity before any request reaches the backend at all.&lt;/p&gt;

&lt;p&gt;Point a browser at a protected URL, IAP bounces it through a Google login once, and a session cookie carries it for the rest of the day. For a browser-facing staging environment or internal tool, that's most of what "secure it" or "limit access to it" ends up meaning in practice — no VPN client to distribute, no user database to run, access that IAM already tracks. That ease is specific to browsers, though. The moment the client isn't one — a mobile app's embedded WebView, a CI job, a script hitting the same backend — IAP's contract changes completely, and it's worth knowing that cost before leaning on IAP for more than the browser case.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fojn5665u6dyrfjq0ambv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fojn5665u6dyrfjq0ambv.jpg" alt="A browser redirected from a protected URL into Google's IAP sign-in wall" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;IAP (Identity-Aware Proxy)&lt;/strong&gt;&lt;br&gt;
A Google Cloud layer in front of a load balancer's backend service that requires a verified Google identity before any request reaches the backend. Toggled per backend service, not per URL path.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Mechanically, it's an auth check bolted onto GCLB (Google Cloud Load Balancer): a request hits the load balancer, IAP checks for a valid identity — a session cookie from a prior OAuth flow, or a bearer token with the right audience — and either lets the request through or bounces it into a Google sign-in flow. For a browser, that flow is a redirect and a cookie. For anything else, there's no redirect to follow and no cookie to hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why teams reach for it
&lt;/h2&gt;

&lt;p&gt;The appeal isn't the auth mechanism itself — OAuth redirects and session cookies are nothing new. It's what you &lt;em&gt;don't&lt;/em&gt; have to build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Staging and internal tools stay off the public internet without standing up a VPN or writing your own login layer.&lt;/li&gt;
&lt;li&gt;Access rides on identities your org already has (Google Workspace), not a separate user database.&lt;/li&gt;
&lt;li&gt;It's a toggle on an existing backend service, not another service to run, patch, and keep available.&lt;/li&gt;
&lt;li&gt;Access is a list in IAM, not a config buried in application code — add or remove a person there, and it takes effect immediately, everywhere that backend is used.&lt;/li&gt;
&lt;li&gt;Every access decision lands in Cloud Audit Logs, so "who hit staging and when" is a query, not an archaeology project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a browser-facing surface, that's close to free. The tax shows up somewhere else entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up IAP to limit access to staging
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Enable IAP on a backend service.&lt;/strong&gt; In the GCP console, under the load balancer's backend service, flip the IAP toggle. This is per backend service, not per URL path — anything routed to that backend is now gated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm2fxz364yccbc56a8cun.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm2fxz364yccbc56a8cun.jpg" alt="The IAP toggle enabled for a single GCLB backend service in the GCP console" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Grant access.&lt;/strong&gt; Under IAM, add the principals (individual users, a Google group, or a service account) that should get through, with the &lt;strong&gt;IAP-Secured Web App User&lt;/strong&gt; role. Nothing else grants access — being a project owner doesn't bypass IAP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Folhx3iyj4xf48y1wolyj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Folhx3iyj4xf48y1wolyj.jpg" alt="A principal granted the IAP-Secured Web App User role in IAM" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Verify the browser flow.&lt;/strong&gt; Hit the protected URL in a browser as a granted principal — expect the Google sign-in redirect once, then normal access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Get a token for a non-browser client.&lt;/strong&gt; For a quick manual test, &lt;code&gt;gcloud auth print-identity-token --audiences=&amp;lt;IAP_CLIENT_ID&amp;gt;&lt;/code&gt; mints a token you can attach directly. In an app, that's a service-account-signed JWT or an OIDC client credential exchange instead, but the shape of the request is the same either way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Without a token: IAP redirects to the login page&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://stage.example.com/

&lt;span class="c"&gt;# With a correctly-audienced bearer token: passes straight through&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-identity-token &lt;span class="nt"&gt;--audiences&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$IAP_CLIENT_ID&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://stage.example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a browser is the only client that needs through, that's the whole setup. If a mobile app, a CI job, or a script also needs to reach the same backend, the next section is the real cost of that — worth reading before assuming IAP covers those the same way it covers a browser tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops being invisible: web vs. non-browser clients
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the web&lt;/strong&gt;, the free lunch is real: no client code, no token management, a cookie the browser already knows how to carry. The one thing worth knowing is that it's still an interactive flow — anything that can't render a redirect and complete a Google login (headless browsers, most testing tools) hits the same wall a non-browser client does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Off the web&lt;/strong&gt;, none of that machinery exists. IAP doesn't issue a session cookie for token-based auth — there's nothing to persist, nothing to keep warm. A non-browser client has to attach a bearer ID token to every request it wants to get through, and that's where the actual complexity of "add IAP" lives. Four things commonly go wrong, in roughly the order teams find them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The wrong header wins.&lt;/strong&gt; &lt;code&gt;Proxy-Authorization&lt;/code&gt; is the technically correct header for talking to an intermediary like IAP — it's meant to be stripped before the request reaches your own backend. In practice, GCLB's IAP implementation doesn't reliably honor it, and on Android, WebViews reject it outright on document requests (&lt;code&gt;ERR_INVALID_ARGUMENT&lt;/code&gt;). What actually works is &lt;code&gt;Authorization&lt;/code&gt; — the same header slot most apps already use for their own session. That's an awkward collision, not an elegant design; it's just what's left once the "correct" option turns out not to work reliably.&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;// Attach the IAP token only to the initial document load&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stageUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iapToken&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;iapToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;The token has the wrong audience.&lt;/strong&gt; A client library's default sign-in call often hands back an ID token scoped to &lt;em&gt;itself&lt;/em&gt; — "this is the mobile app" — not to IAP. Send that one and IAP rejects it with an audience mismatch, even though the token is valid, signed, and unexpired. The token that clears IAP has to be requested separately and checked before it's trusted:&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;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;requestAndCacheToken&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;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// the IAP-audience token, not the default sign-in one&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;decodeAud&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="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;IAP_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// cached session, wrong audience — force re-auth instead of trusting it&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reauthenticate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestAndCacheToken&lt;/span&gt;&lt;span class="p"&gt;();&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="nf"&gt;notify&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="nx"&gt;token&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;Boolean&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="nx"&gt;token&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;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[IAPAuthClient] token fetch failed&lt;/span&gt;&lt;span class="dl"&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notify&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;aud&lt;/code&gt; vs. &lt;code&gt;azp&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
Both show up in an OIDC token and look interchangeable until a request gets rejected. &lt;code&gt;aud&lt;/code&gt; is who the token is &lt;em&gt;for&lt;/em&gt; — what IAP checks against its allowlisted clients. &lt;code&gt;azp&lt;/code&gt; is who actually &lt;em&gt;requested&lt;/em&gt; it — the client's own identity. Mixing them up produces a token that's completely valid and still gets bounced.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Everything the header doesn't reach still goes through IAP.&lt;/strong&gt; A bearer token on the document request only covers that one request. Every automatic follow-up request a page makes — &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt;, stylesheets, fonts — hits the same IAP-protected backend with no header attached, gets redirected to the Google login page, and comes back as HTML. Browsers then hit &lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt;, refuse to execute HTML as JavaScript, and the page just... stops. No error, no crash, just a static shell that never hydrates.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
Tells the browser not to guess a resource's type from its content. When IAP swaps a JS file for an HTML login page, this header is what stops the browser from quietly trying to run HTML as a script — it refuses instead, silently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's no client-side fix for this — a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag's request can't carry a custom header. The actual fix is infrastructural: serve static assets from a second backend service that isn't behind IAP at all, the same pattern most production setups already use for CDN-hosted, content-hashed bundles. Since IAP toggles per backend service and not per URL path, this means standing up a second backend and a URL-map split, not flipping a flag.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant Client
    participant IAP as IAP-gated LB
    participant Bypass as Non-IAP backend
    Client-&amp;gt;&amp;gt;IAP: GET /page  (Authorization: Bearer &amp;lt;token, aud=IAP client&amp;gt;)
    IAP--&amp;gt;&amp;gt;Client: 200 HTML shell
    Client-&amp;gt;&amp;gt;Bypass: GET /dist/app.bundle.js  (no header)
    Bypass--&amp;gt;&amp;gt;Client: 200 application/javascript
    Note over Client: page hydrates normally&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;strong&gt;The client fan-out cost.&lt;/strong&gt; Every environment an OAuth client needs to reach IAP for is its own client registration — and on Android, its own SHA-1 fingerprint per signing key. Give every stage tier its own bundle ID and you're registering a client (and, per developer, a debug-keystore fingerprint) for every environment × every machine. That stops scaling past a handful of people fast. Teams that hit this usually collapse it the same way: one non-prod build with a runtime environment picker instead of a build-time bundle ID per tier, so the OAuth client count stays fixed regardless of how many stage environments exist.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A[EnvironmentPicker: pick backend] --&amp;gt; B[persist choice]
    B --&amp;gt; C[hydrateRuntimeConfig on boot]
    C --&amp;gt; D["config.* resolves to selected preset\n(webview URL, IAP client, feature flags)"]
    D --&amp;gt; E[AuthGate: does this preset need IAP?]
    E --&amp;gt;|yes| F[Google sign-in -&amp;gt; IAP token]
    E --&amp;gt;|no| G[skip straight through]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Two smaller gotchas round this out, both specific to embedding a web page inside a native WebView rather than to IAP itself: cookies leaking in from the native app's own API calls can corrupt the page's hydration state (fixed by running the WebView &lt;code&gt;incognito&lt;/code&gt;, with &lt;code&gt;sharedCookiesEnabled={false}&lt;/code&gt;), and naively remounting the WebView to reapply the header on navigation cancels every in-flight tap, not just the one redirect it meant to catch — the fix is swapping &lt;code&gt;source&lt;/code&gt; in place instead of remounting.&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;GatedWebView&lt;/span&gt;
  &lt;span class="nx"&gt;incognito&lt;/span&gt;
  &lt;span class="nx"&gt;sharedCookiesEnabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;onNavigationStateChange&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleNavigationStateChange&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put together, that's a real amount of work to make a non-browser client behave like a browser IAP was designed for. &lt;strong&gt;It's worth asking, before signing up for it, whether the non-browser client needs to talk to the IAP-gated backend at all.&lt;/strong&gt; If most of that access is internal — a CI job hitting staging, a build script, a developer's local tooling — a short-lived service-account token issued at build time, or just a VPN, is often simpler than teaching every client to carry a bearer token correctly. IAP earns its keep when the &lt;em&gt;end user&lt;/em&gt; needs individually attributable, revocable access. It's a heavier tool than necessary for access that's really "this machine is allowed to talk to that machine."&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternatives to IAP
&lt;/h2&gt;

&lt;p&gt;IAP isn't the only way to gate a backend behind identity — worth knowing what else is on the table before committing to it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Access&lt;/strong&gt; — the same shape as IAP (edge proxy, OAuth redirect, per-request policy), but at Cloudflare's edge instead of GCLB, and not tied to GCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale / Tailscale Funnel&lt;/strong&gt; — network-level identity over WireGuard; a client joins the tailnet once instead of carrying a token per request, which removes the header/audience problems entirely, at the cost of every client needing the Tailscale client installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS ALB + Cognito&lt;/strong&gt; — the AWS-native equivalent of the same redirect-and-cookie pattern, for teams already on that load balancer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plain VPN&lt;/strong&gt; — gates at the network layer, completely invisible to the application; simple for internal-only, non-browser access, but the VPN client itself becomes the thing every user and machine has to run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mTLS&lt;/strong&gt; — certificate-based, and uniform across browser and non-browser clients since there's no OAuth redirect involved at all; the cost moves to certificate distribution and rotation instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A custom JWT-checking middleware&lt;/strong&gt; — full control over the token shape and validation logic, and full ownership of maintaining it, which is exactly the auth layer IAP exists to let you skip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deciding question is usually whether your non-browser clients can tolerate a redirect-based flow at all. If they can, IAP or Cloudflare Access are the least amount of new infrastructure. If they can't — or if the access is really machine-to-machine — a VPN, Tailscale, or mTLS avoids the token dance altogether.&lt;/p&gt;

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

&lt;p&gt;Securing a staging environment usually just means keeping it off the open internet while letting the right people straight through, and for a browser-facing surface, IAP does that with a toggle and an IAM entry instead of a service you have to run. It stops being free the moment a non-browser client needs through — budget for that as a real cost, not a footnote, and check first whether that client actually needs individually-attributable access at all, or just network-level access a simpler tool already provides.&lt;/p&gt;

</description>
      <category>googlecloud</category>
      <category>identityawareproxy</category>
      <category>oauth</category>
      <category>security</category>
    </item>
    <item>
      <title>Composition Over Configuration Is a Rule I Keep Re-Deriving</title>
      <dc:creator>Soham Mondal</dc:creator>
      <pubDate>Tue, 01 Sep 2026 06:59:13 +0000</pubDate>
      <link>https://dev.to/sohammondal/composition-over-configuration-is-a-rule-i-keep-re-deriving-19o6</link>
      <guid>https://dev.to/sohammondal/composition-over-configuration-is-a-rule-i-keep-re-deriving-19o6</guid>
      <description>&lt;p&gt;Every settings page in a growing app ends up with a modal that opens for profile info, or billing, or notification preferences, whatever product asked for last sprint. The lazy way to build that is one modal component with a &lt;code&gt;section&lt;/code&gt; prop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SettingsSection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile&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;billing&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;notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SettingsPanelChrome&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;size&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;compact&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;wide&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;dismissible&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="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SettingsModalProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;section&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SettingsSection&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="nl"&gt;onClose&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="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SettingsSection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SettingsPanelChrome&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;footer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&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="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SettingsSection&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;React&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly the shape a settings modal takes the first time someone builds it, before a refactor forces the issue. It looks reasonable. It compiles. It even feels DRY — one modal, one footer, one set of chrome rules, reused across every section. The catch shows up the next time product asks for a fourth section: you touch the &lt;code&gt;section&lt;/code&gt; union, you touch the &lt;code&gt;chrome&lt;/code&gt; record, you touch the &lt;code&gt;children&lt;/code&gt; discriminator inside the modal, and you touch whatever component was rendering based on &lt;code&gt;active&lt;/code&gt;. Four call sites for one new section, all inside a component that was supposed to be generic.&lt;/p&gt;

&lt;p&gt;I keep running into this shape and keep tearing it back out. Not as a one-off cleanup — as a rule I apparently have to re-derive every few weeks, because the config-object version is always the one that gets written first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flq3d4ey92889gnsetdje.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flq3d4ey92889gnsetdje.jpg" alt="A single configurable box branching into three outcomes on the left, versus three separate self-contained boxes on the right" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The anti-pattern, defined once
&lt;/h2&gt;

&lt;p&gt;A shared component that switches on a string or union discriminator — &lt;code&gt;mode&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;variant&lt;/code&gt; — with a config map keyed by that same discriminator sitting next to it. New variant means editing the component that was supposed to be closed for modification. It reads like reuse. It's actually coupling: every variant now depends on the shared dispatcher knowing about it by name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case one: the settings-modal god-component
&lt;/h2&gt;

&lt;p&gt;A modal-and-nav pair built around that &lt;code&gt;section&lt;/code&gt; discriminator and a &lt;code&gt;chrome&lt;/code&gt; record supplying per-section titles and sizing had grown to a few hundred lines before I replaced both with one self-contained component per section — &lt;code&gt;ProfileSettings&lt;/code&gt;, &lt;code&gt;BillingSettings&lt;/code&gt;, &lt;code&gt;NotificationSettings&lt;/code&gt; — each owning its own modal ref, its own content, its own footer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ProfileSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modalRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ModalHandle&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updateProfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;saveProfile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useProfileForm&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsNavItem&lt;/span&gt;
        &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Profile"&lt;/span&gt;
        &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;UserIcon&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;modalRef&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;open&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Modal&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;modalRef&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;ProfileForm&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;updateProfile&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;SaveButton&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;saveProfile&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;Modal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nav itself became a plain composition root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AccountSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FC&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsNav&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;ProfileSettings&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;BillingSettings&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;NotificationSettings&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;SettingsNav&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fourth section now means writing a fourth file and adding one line to that list. Nothing else moves.&lt;/p&gt;

&lt;p&gt;The payoff isn't just fewer files touched per new section — it's that &lt;code&gt;AccountSettings&lt;/code&gt; can now be recomposed per surface without anyone touching a settings component. A support-tools screen that only needs billing and notifications doesn't need a &lt;code&gt;visibleSections&lt;/code&gt; prop threaded through the old modal; it just renders fewer children:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SupportToolsSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FC&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsNav&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;BillingSettings&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;NotificationSettings&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;SettingsNav&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the actual argument for composition over configuration, not just the tidier one: a config object can only do what its author anticipated (add a &lt;code&gt;visibleSections&lt;/code&gt; array, thread it through every layer, remember to keep it in sync). A tree of components can be rearranged by anyone, from outside, without touching the pieces being rearranged.&lt;/p&gt;

&lt;p&gt;The nav item that sits in that list had its own smaller version of the same disease — a hand-rolled props type duplicating fields the design system's &lt;code&gt;Tab&lt;/code&gt; already exposed. I fixed that the same day, by picking from the library type instead of re-declaring it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SettingsNavItemProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
  &lt;span class="nx"&gt;TabProps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;icon&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;badge&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;onPress&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;disabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;isActive&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a narrower version of the same instinct: don't build your own config shape when a real one already exists next to you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Composition root&lt;/strong&gt;&lt;br&gt;
A component whose only job is to arrange other components — no branching, no business logic, no shared state of its own. &lt;code&gt;AccountSettings&lt;/code&gt; above is one: it decides &lt;em&gt;which&lt;/em&gt; sections exist, not how any single section behaves.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96nyz3uqd73afla90slu.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96nyz3uqd73afla90slu.jpg" alt="One crowded settings dialog with overlapping tabs on the left, versus three separate clean settings cards for profile, billing, and notifications on the right" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the whole shape of the fix: three small things a caller can point at directly, instead of one thing a caller has to configure correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that made it stick
&lt;/h3&gt;

&lt;p&gt;Fixing the god-component once doesn't stop the next PR from reaching for a &lt;code&gt;section&lt;/code&gt; prop again — it's the cheapest thing to write under deadline, and it compiles cleanly the first time. So in the same change that shipped the breakup, I also wrote down the judgment call as a rule file the AI coding agent reads before it touches this part of the codebase again — roughly:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Split&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;shared&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;containers&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;branch&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mode/type/variant&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prop&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;into&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;one&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;self-contained&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;component&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;per&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;variant"&lt;/span&gt;
&lt;span class="na"&gt;globs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.tsx"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Composition over configuration&lt;/span&gt;

If a component picks its content, footer, or layout based on a
discriminator prop, that's a sign the variants should be separate
components instead. Give each variant its own trigger, its own
content, its own actions, and let the parent just arrange them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a wiki page — something the agent actually reads before it writes the next settings component. That's the whole judgment call, captured once so it doesn't depend on me remembering it three PRs from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case two: the union that grew back two weeks later
&lt;/h2&gt;

&lt;p&gt;Two weeks after the settings-modal cleanup, I was back in the same feature area rewriting the toggle primitives underneath it, and the same shape had regrown somewhere else. The shared toggle option type carried two ways to produce a label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ToggleOptionLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="na"&gt;render&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;React&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolveLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToggleOptionLabel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every toggle using the shared option type — a dozen of them, across profile, billing, and notification preferences — had to import &lt;code&gt;resolveLabel&lt;/code&gt; and call it before rendering. A branch inside a mapper, fanned out across a dozen call sites, to solve a problem that didn't need solving: every option in practice just needed a plain string. The &lt;code&gt;render&lt;/code&gt; branch existed for a customization case that, once I actually checked, no call site used.&lt;/p&gt;

&lt;p&gt;I deleted the label-union type and the resolver — a customization escape hatch nobody had reached for — and gave the toggle a single field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ToggleOption&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Value&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;value&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="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;ToggleIconComponent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ToggleOption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FC&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ToggleOptionProps&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ToggleButton&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ToggleButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every call site got simpler in the same diff:&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;NOTIFICATION_OPTIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToggleOption&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;NotificationChannel&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;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NotificationChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Mail&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NotificationChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PUSH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Push&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Bell&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;I didn't think of this as a bugfix or a cleanup while I was doing it. Looking back at the diff afterward, it was plainly just replacing a config builder with direct composition — fewer files, fewer indirections, the same behavior.&lt;/p&gt;

&lt;p&gt;The same change did the mirror-image version of the fix on the compound component wrapping every settings group. It used to take a &lt;code&gt;contentSpacing?: 'compact' | 'regular'&lt;/code&gt; prop and switch internally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Content&lt;/span&gt; &lt;span class="o"&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="nx"&gt;spacing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;regular&lt;/span&gt;&lt;span class="dl"&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;spacing&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;compact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-compact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-regular&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the same discriminator problem in miniature — a config flag deciding which style branch runs inside one shared node. I split it into two real leaves instead of one leaf with a mode switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Content&lt;/span&gt; &lt;span class="o"&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;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"content-regular"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CompactContent&lt;/span&gt; &lt;span class="o"&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;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"content-compact"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SettingsGroup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;List&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SettingsGroupComponent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;SettingsGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;SettingsGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CompactContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CompactContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Callers now write &lt;code&gt;&amp;lt;SettingsGroup.CompactContent&amp;gt;&lt;/code&gt; when they want compact spacing, instead of passing a flag into a component that decides for them. Composition over configuration cuts both ways: sometimes it means deleting a union, sometimes it means turning a config prop into an actual leaf a caller can choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, stated plainly
&lt;/h2&gt;

&lt;p&gt;Neither fix was hard once I saw it. What's consistent is that I didn't see either one the first time a config object got written — I saw it weeks later, once a second variant made the branching visibly expensive. A &lt;code&gt;mode&lt;/code&gt; prop with one value is invisible; with two, it still looks cheaper than two new files. It only starts looking wrong around the third or fourth branch, by which point it's load-bearing for every call site depending on it.&lt;/p&gt;

&lt;p&gt;If two variants share most of their behavior, that's not evidence they should be one component with a switch — it's evidence they'll fork eventually, and the switch just defers the fork to a worse moment. Two small self-contained things, composed by a thin parent, beats one configurable thing almost every time the second variant is real rather than hypothetical. That's why a rule file earns its keep here in a way a PR comment doesn't: a comment fixes the diff in front of you; a rule file gets read the next time an agent — or me, a few weeks from now — is about to write the fifth branch of a config map, before it exists to comment on.&lt;/p&gt;

&lt;p&gt;I still expect to re-derive this on some other screen. That's fine — the rule file just needs to catch it faster next time, not make sure I never write a &lt;code&gt;mode&lt;/code&gt; prop again.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>componentdesign</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why My React App Still Runs on Singleton Classes</title>
      <dc:creator>Soham Mondal</dc:creator>
      <pubDate>Tue, 01 Sep 2026 06:55:01 +0000</pubDate>
      <link>https://dev.to/sohammondal/why-my-react-app-still-runs-on-singleton-classes-2b7m</link>
      <guid>https://dev.to/sohammondal/why-my-react-app-still-runs-on-singleton-classes-2b7m</guid>
      <description>&lt;p&gt;React spent the last decade training developers that a class is a code smell. Class components got deprecated, hooks won, and "just write a function" became the default advice for almost everything. That advice runs into a wall the moment a piece of code has to run outside a component: an HTTP interceptor, an event listener, a background task, a deep-link handler. None of those have a render tree to sit inside, which means none of them can call a hook.&lt;/p&gt;

&lt;p&gt;That's not a style opinion. It's a hard constraint. It's also the reason core pieces of infrastructure in most non-trivial React codebases — auth tokens, feature flags, routing rules, device identity, analytics — end up as classes, usually singletons, imported directly instead of consumed through a hook or a context provider.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgpzltstztjo69f4uc2a4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgpzltstztjo69f4uc2a4.jpg" alt="Three unrelated boxes each drawing a line to one shared central box, with a crossed-out duplicate off to the side" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The render-tree boundary problem
&lt;/h2&gt;

&lt;p&gt;A hook only exists while its component exists. &lt;code&gt;useState&lt;/code&gt; allocates memory tied to a place in React's tree; the moment that component unmounts, the state is gone, and before it mounts, the state isn't reachable at all. That's fine for almost everything a component owns. It stops being fine the moment something &lt;em&gt;outside&lt;/em&gt; the tree needs the same piece of state.&lt;/p&gt;

&lt;p&gt;Authentication is the clearest version of this. A typical setup keeps the access token in a hook, refreshed on a timer, exposed to whatever component needs it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;useSessionTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;UseSessionTokens&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;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setTokens&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&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;AuthTokens&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;refreshAccessToken&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;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;tokens&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newTokens&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;refreshAndSetTokens&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshToken&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;setTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTokens&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;newTokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&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;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfectly normal hook. The problem shows up one layer down: an HTTP client's request interceptor is a plain function, registered once at app boot, running completely outside React's render tree. It can't call &lt;code&gt;useSessionTokens()&lt;/code&gt; — it isn't a component, and never will be. Whatever token that hook is holding in memory is invisible to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A synchronous, shared source of truth
&lt;/h2&gt;

&lt;p&gt;Without access to the hook's in-memory value, the interceptor's only remaining option is to go around it — read the token from wherever it was last persisted, on every single outgoing request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestInterceptor&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InternalAxiosRequestConfig&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;accessToken&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;secureTokenStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAccessTokenAsync&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;accessToken&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;config&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="nf"&gt;isTokenValid&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Authorization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&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 secure-storage round-trip on every request, not because the app doesn't already have the token in memory, but because the place holding it is locked inside a React tree the interceptor lives outside of.&lt;/p&gt;

&lt;p&gt;The fix is to move the token out of the hook and into a class that isn't tied to any component's lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SessionTokenManager&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SessionTokenManager&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuthTokens&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&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="nx"&gt;appLifecycle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AppLifecycleObserver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&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="nf"&gt;setupAppLifecycleListener&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;SessionTokenManager&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;instance&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;SessionTokenManager&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;getTokens&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&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;getTokens()&lt;/code&gt; is synchronous. No &lt;code&gt;await&lt;/code&gt;, no secure-storage call, no dependency on where in the tree the caller happens to be standing. The interceptor calls it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestInterceptor&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InternalAxiosRequestConfig&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;sessionTokenManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SessionTokenManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&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;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionTokenManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTokens&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;tokens&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&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="nf"&gt;isTokenValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Authorization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tokens&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So does the auth provider, memoizing the same instance so React doesn't re-fetch it every render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionTokenManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SessionTokenManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&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;So does any component several layers deep that needs an auth header and has no reason to thread that dependency through props — it just calls &lt;code&gt;SessionTokenManager.getInstance().getTokens()&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;No prop drilling, no context provider, and — the part that actually matters — one shared source of truth instead of separate code paths that could each independently decide the token was stale.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;private constructor&lt;/code&gt; + &lt;code&gt;getInstance()&lt;/code&gt; instead of just &lt;code&gt;new SessionTokenManager()&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
A private constructor makes "there is exactly one of these" a compile error, not a convention. Nothing can accidentally instantiate a second &lt;code&gt;SessionTokenManager&lt;/code&gt; with its own stale copy of the tokens and its own refresh timer running independently of the first one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When branching logic needs one shared entry point
&lt;/h2&gt;

&lt;p&gt;The token manager solves a state problem. The same reasoning applies to a control-flow problem: deciding whether a route is even allowed to render.&lt;/p&gt;

&lt;p&gt;A common shape for that starts as one function with a growing pile of nested &lt;code&gt;if&lt;/code&gt; statements, one per guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolveRouteGuard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;featureFlags&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&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;PUBLIC_ROUTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&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;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasCompletedOnboarding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/onboarding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/onboarding&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="c1"&gt;// then feature-flag gates, then maintenance mode, then role checks...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every new guard means another &lt;code&gt;if&lt;/code&gt; block in a function that already has five reasons to change, and each one has to be careful not to trip over the guards ordered before it — swap two of these &lt;code&gt;if&lt;/code&gt;s and a logged-out user can land on the onboarding screen instead of the login screen. Testing one guard means faking enough state to get past every earlier &lt;code&gt;if&lt;/code&gt; first. Replacing that function with an abstract class and one subclass per guard fixes both problems at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RouteGuard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;shouldHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteContext&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;abstract&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;RouteOutcome&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthGuard&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RouteGuard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;shouldHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteContext&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="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&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;PUBLIC_ROUTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;RouteOutcome&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;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a runner that knows nothing about auth, onboarding, or feature flags — it just walks the 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;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RouteGuardRunner&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;guards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteGuard&lt;/span&gt;&lt;span class="p"&gt;[])&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;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;featureFlags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FeatureFlags&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;RouteOutcome&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buildContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;featureFlags&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;of&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;guards&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;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&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;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;routeGuardRunner&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;RouteGuardRunner&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AuthGuard&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OnboardingGuard&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FeatureFlagGuard&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MaintenanceModeGuard&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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Chain of responsibility&lt;/strong&gt;&lt;br&gt;
Each guard decides independently whether it applies (&lt;code&gt;shouldHandle&lt;/code&gt;) and, if so, what to do about it (&lt;code&gt;handle&lt;/code&gt;). The caller never branches on guard type — it just asks each one in order and stops at the first match. That turns "one function that knows about four guards" into "four files that each know about one," and each guard gets its own test file, testable without faking the other three guards' preconditions first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same shape shows up anywhere a chain of independent, ordered checks needs to run — resolving an incoming deep link to the right screen, or dispatching a webhook payload to the right handler, is a near-identical problem wearing different names. Once a pattern like this exists in a codebase, copying the working shape is cheaper than re-deriving one from a fresh pile of &lt;code&gt;if&lt;/code&gt; statements the next time a similar problem shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for it
&lt;/h2&gt;

&lt;p&gt;None of this is really about the &lt;code&gt;class&lt;/code&gt; keyword, and it isn't an argument for OOP as a philosophy. It's about recognizing when a piece of code has to keep working outside the boundaries React manages automatically. Three conditions are the actual trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The code has to run outside the render tree.&lt;/strong&gt; An interceptor, an event listener, a deep-link handler, a background task — anything registered once at boot and invoked independently of whether a component is currently mounted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Several unrelated call sites need to agree on one shared, mutable value.&lt;/strong&gt; If three different files can each independently decide a token is stale, or start their own refresh timer, that's effectively a race condition with extra steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The code wraps a third-party SDK that already has its own imperative lifecycle.&lt;/strong&gt; A remote-config SDK, an analytics SDK, a native module bridge — these come with their own &lt;code&gt;init()&lt;/code&gt; / &lt;code&gt;subscribe()&lt;/code&gt; / teardown, and forcing that lifecycle through &lt;code&gt;useEffect&lt;/code&gt; usually means fighting the SDK instead of using it.&lt;/p&gt;

&lt;p&gt;A device-identity service is a compact example of the first and third conditions together: it generates a random ID once on first install and has to reuse it forever, and &lt;code&gt;initialize()&lt;/code&gt; has to be safe to call from multiple places at boot without a race creating two different IDs. The fix is a private &lt;code&gt;initializationPromise&lt;/code&gt; field — concurrent callers await the same in-flight write instead of each kicking off their own. A bare exported &lt;code&gt;initializeDeviceId()&lt;/code&gt; function could do the same thing, but only by smuggling that promise into a module-level variable — at which point it's a class with extra steps and no &lt;code&gt;private&lt;/code&gt; keyword stopping some other file from reading or resetting it by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforced vs. conventional singletons
&lt;/h2&gt;

&lt;p&gt;Not every singleton enforces its own uniqueness the same way, and the difference is worth being precise about.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SessionTokenManager&lt;/code&gt; and the device-identity service use a private constructor and &lt;code&gt;static getInstance()&lt;/code&gt; — the compiler physically stops anything from writing &lt;code&gt;new SessionTokenManager()&lt;/code&gt; a second time. Clients that wrap an already-singleton third-party SDK usually skip that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FeatureConfigClient&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="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="nx"&gt;instance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getRemoteConfig&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;featureConfig&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;FeatureConfigClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just a plain constructor, instantiated once at module scope, exported as a &lt;code&gt;const&lt;/code&gt; — the same shape an analytics client or an attribution client gets. Nothing stops another file from writing &lt;code&gt;new FeatureConfigClient()&lt;/code&gt; — it's a convention, not a guarantee. That's a deliberate downgrade from the enforced version, and usually the right call: these clients wrap SDKs that are already global singletons under the hood, so a second instance would be redundant rather than dangerous. The token manager gets the stricter treatment because a second instance really would drift — its own refresh timer, its own in-memory copy of tokens that could disagree with the first one about whether the user is even logged in.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Enforced vs. conventional&lt;/strong&gt;&lt;br&gt;
Reach for the enforced version (&lt;code&gt;private constructor&lt;/code&gt; + &lt;code&gt;getInstance()&lt;/code&gt;) when a second instance would cause real drift — separate timers, separate in-memory state that can disagree with the first. Reach for the conventional version (plain constructor, instantiated once at module scope) when the class is a thin wrapper around something that's already a singleton elsewhere, like a third-party SDK — the guarantee already exists one layer down, so enforcing it again is just ceremony.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A comment worth stealing, usually sitting near a context provider in codebases that get this right, says the rule out loud better than a paragraph can:&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 holds only reactive state — stable clients are module-level singletons&lt;/span&gt;
&lt;span class="c1"&gt;// imported directly: `import { analyticsClient } from 'core/analytics'`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Context should carry only the couple of values that actually change and that components need to re-render on — an anonymous ID, an attribution ID. The SDK clients themselves never go through context: there's nothing reactive about them, so putting one there just means every consumer pays a &lt;code&gt;useContext&lt;/code&gt; subscription for a value that's never going to change out from under them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoffs
&lt;/h2&gt;

&lt;p&gt;These wins aren't free, and the costs are worth naming plainly instead of only selling the upside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing gets harder.&lt;/strong&gt; A hook resets itself for free between test runs — a new render, a new &lt;code&gt;useState&lt;/code&gt;. A singleton doesn't. If a test calls &lt;code&gt;SessionTokenManager.getInstance()&lt;/code&gt; and mutates it, every test that runs afterward inherits that state unless something explicitly resets &lt;code&gt;.instance&lt;/code&gt; to &lt;code&gt;undefined&lt;/code&gt; between runs — usually a &lt;code&gt;resetInstance()&lt;/code&gt; method or a &lt;code&gt;beforeEach&lt;/code&gt; block. Skip that and the failure doesn't show up loudly: tests pass in isolation and fail only when run together, because they're all sharing one instance's leftover state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's no automatic cleanup.&lt;/strong&gt; A hook's &lt;code&gt;useEffect&lt;/code&gt; return function runs on unmount, for free, every time. A singleton has no unmount — if it holds a timer, a subscription, or a listener, something has to remember to tear it down explicitly, and there's no lifecycle event that guarantees that will happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's easy to overuse.&lt;/strong&gt; The same properties that make a singleton useful for a token manager — global reachability, no need to thread it through props — make it just as easy to reach for when the real fix is a context provider or just lifting state up. A codebase where every shared value quietly becomes a singleton loses the thing React's data flow is supposed to provide: the ability to look at a component and know where its data comes from.&lt;/p&gt;

&lt;p&gt;That's not an argument for bringing back class components — most of a React app should still be function components, and most shared logic should still be a hook. Reach for a class, and consider making it a singleton, only when the three conditions above actually apply. What decides it, every time, is whether the thing needs to still be there when no component is rendering: &lt;code&gt;SessionTokenManager.getInstance()&lt;/code&gt; exists before the first screen mounts and after the last one unmounts, because whatever depends on it doesn't care whether anything is currently on screen.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>singletonpattern</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How We Keep a Trunk-Based Pipeline From Being Reckless</title>
      <dc:creator>Soham Mondal</dc:creator>
      <pubDate>Tue, 25 Aug 2026 18:52:20 +0000</pubDate>
      <link>https://dev.to/sohammondal/how-we-keep-a-trunk-based-pipeline-from-being-reckless-250j</link>
      <guid>https://dev.to/sohammondal/how-we-keep-a-trunk-based-pipeline-from-being-reckless-250j</guid>
      <description>&lt;p&gt;&lt;a href="https://sohammondal.com/blog/why-we-ditched-git-flow-for-trunk-based-mobile-releases" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; covered the routing decision: the orchestrator checks the native fingerprint and decides whether a release can go out as an OTA update or needs a new binary. Useful, but incomplete. A gate that answers "is this runtime-compatible" says nothing about whether the change is actually good.&lt;/p&gt;

&lt;p&gt;That is the part people get wrong when they hear "we ship from &lt;code&gt;main&lt;/code&gt;." They picture chaos. What they should picture is a stricter PR gate, heavier feature-flag discipline, and a hotfix path that is more explicit than the git-flow version ever was.&lt;/p&gt;

&lt;p&gt;If every merge to &lt;code&gt;main&lt;/code&gt; can theoretically reach production in minutes, then the safety net cannot be "we'll probably notice something during the release train." The safety has to live inside the pipeline.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Trunk-based is not lower process&lt;/strong&gt;&lt;br&gt;
It is lower branch ceremony and higher automation discipline. If your test gate, flags, and hotfix routing are weak, trunk-based will expose that fast.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi951ko39eesss7uuu787.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi951ko39eesss7uuu787.jpg" alt="Someone walking a tightrope where the safety net is woven into the rope itself" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The PR gate
&lt;/h2&gt;

&lt;p&gt;Every pull request into &lt;code&gt;main&lt;/code&gt; runs through the same automated gate before it is mergeable: typecheck, lint, test suite, and end-to-end checks. None of that is negotiable. That is the floor.&lt;/p&gt;

&lt;p&gt;The exact tools matter less than the contract. A PR should not become releasable because a reviewer felt good about it. It should become releasable because the same mechanical checks passed every other healthy change before it.&lt;/p&gt;

&lt;p&gt;That gate usually looks boring, which is good:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;typecheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run typecheck&lt;/span&gt;

  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run lint&lt;/span&gt;

  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test -- --runInBand&lt;/span&gt;

  &lt;span class="na"&gt;e2e&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;typecheck&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run e2e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F891yngtfm24w7r5jn9v0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F891yngtfm24w7r5jn9v0.jpg" alt="A checkpoint gate with three checked posts and one still under construction" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing exotic under the hood: ESLint, Jest, React Native Testing Library, whatever your E2E layer is, plus local hooks so the obvious failures get caught before CI has to. Popular, boring, well-documented tooling on purpose. The value is not in a fancy test stack. The value is that &lt;code&gt;main&lt;/code&gt; has a contract.&lt;/p&gt;

&lt;p&gt;The contract is simple: if a change lands, it is expected to be shippable.&lt;/p&gt;

&lt;p&gt;That does not mean every merge must go live instantly. It means the system should not depend on a separate stabilization branch to discover obvious breakage later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature flags are the real safety valve
&lt;/h2&gt;

&lt;p&gt;Here is the rule that makes OTA-from-&lt;code&gt;main&lt;/code&gt; survivable at all: &lt;strong&gt;shipping code and releasing a feature are two different actions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A merge can put code on devices within minutes. Whether users can actually hit that code path is a separate decision, controlled remotely.&lt;/p&gt;

&lt;p&gt;That decoupling is what makes trunk-based development workable for product teams. Nobody has to merge at the exact perfect moment. "Merged" does not have to mean "visible."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Deploy vs release&lt;/strong&gt;&lt;br&gt;
Deploy means the code is present on user devices. Release means users can actually experience it. In a healthy mobile pipeline, feature flags let you separate those two decisions.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&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;enabled&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;featureFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new_checkout_flow&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;userId&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;appVersion&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;enabled&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;NewCheckoutFlow&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;LegacyCheckoutFlow&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;Anything not ready to be user-visible goes behind a flag before it goes near &lt;code&gt;main&lt;/code&gt;: half-finished work, risky rewrites, migrations, pricing experiments, staged onboarding changes, whatever.&lt;/p&gt;

&lt;p&gt;That is not a code review nit. That is release architecture.&lt;/p&gt;

&lt;p&gt;I've used both Firebase Remote Config and Flagsmith for this in production. Different tradeoffs, same core job. Firebase is convenient if you're already deep in that ecosystem and need straightforward targeting. Flagsmith gives you a bit more explicit control once flags start becoming part of how the team routinely operates. Either is fine. What matters is not the vendor. What matters is whether the team actually treats flags as the release valve instead of an occasional convenience.&lt;/p&gt;

&lt;p&gt;One simple pattern that ages well is keeping the risky decision point in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldRenderNewCheckout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;appVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;featureFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;new_checkout_flow&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;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;appVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks small, but centralizing flag evaluation pays off later when you need percentage rollouts, kill switches, environment-aware defaults, or analytics around who saw what.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frub0g6umk42g3ho2p64w.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frub0g6umk42g3ho2p64w.jpg" alt="A wall light switch with a blank tag hanging off it" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The same fingerprint gate, wearing a different hat
&lt;/h2&gt;

&lt;p&gt;The fingerprint check from Part 1 is not just an OTA router. It is also what makes hotfix routing sane.&lt;/p&gt;

&lt;p&gt;Because the gate compares against the exact binary currently in the store, the system always knows one thing with certainty: whether &lt;code&gt;main&lt;/code&gt; could safely reach the devices that are live right now, or whether it has drifted natively since the last binary release.&lt;/p&gt;

&lt;p&gt;That single fact matters a lot during an incident. It answers the first question fast: can we fix this on top of the current trunk, or do we need to cut from the live snapshot?&lt;/p&gt;

&lt;h2&gt;
  
  
  Hotfixing without touching &lt;code&gt;main&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A production fire needs a fix routed to wherever users actually are. After a few routine OTAs, that is often not the same commit as the last full store release. So the pipeline keeps immutable reference points for what is live.&lt;/p&gt;

&lt;p&gt;Two protected snapshots exist for exactly this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One pinned at the last &lt;strong&gt;full store release&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;One pinned at the last &lt;strong&gt;successful OTA&lt;/strong&gt;, if a newer one shipped since.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A hotfix branches off whichever of those two is actually live, ships from there, and only afterward gets merged or cherry-picked forward into &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Neither snapshot branch is a development branch. They are frozen markers. That is what keeps a hotfix from accidentally dragging in unrelated work that landed on &lt;code&gt;main&lt;/code&gt; after the last production-safe point.&lt;/p&gt;

&lt;p&gt;In practice, the routing logic is this plain:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A([Production issue found]) --&amp;gt; B{Does main match the live runtime?}
    B --&amp;gt;|Yes| C[Fix on main]
    C --&amp;gt; D{Fingerprint still matches?}
    D --&amp;gt;|Yes| E[Publish OTA]
    D --&amp;gt;|No| F[Cut full release]
    B --&amp;gt;|No| G[Branch from live snapshot]
    G --&amp;gt; H[Patch only what is needed]
    H --&amp;gt; I{Fingerprint matches snapshot runtime?}
    I --&amp;gt;|Yes| J[Ship hotfix OTA]
    I --&amp;gt;|No| K[Build and submit hotfix binary]
    J --&amp;gt; L[Merge forward into main]
    K --&amp;gt; L
    E --&amp;gt; M([Users receive fix])
    F --&amp;gt; M
    L --&amp;gt; M

    classDef start fill:#f4e1bd,stroke:#171717,color:#171717,stroke-width:2px;
    classDef normal fill:#fffaf2,stroke:#171717,color:#171717,stroke-width:2px;
    classDef decision fill:#efe6d8,stroke:#171717,color:#171717,stroke-width:2px;
    classDef action fill:#f7f1e8,stroke:#171717,color:#171717,stroke-width:2px;
    classDef release fill:#f0b35f,stroke:#171717,color:#171717,stroke-width:2px;

    class A,M start;
    class B,D,I decision;
    class C,G,H,L normal;
    class E,F,J,K release;&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;There is no heroism in this. That is why it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole shape of it
&lt;/h2&gt;

&lt;p&gt;None of these pieces does much on its own.&lt;/p&gt;

&lt;p&gt;The PR gate keeps obvious breakage out.&lt;br&gt;
Feature flags decouple deploy from release.&lt;br&gt;
The fingerprint check protects the native/runtime boundary.&lt;br&gt;
Snapshot branches make hotfix routing explicit instead of improvisational.&lt;/p&gt;

&lt;p&gt;Together, they replace what release managers and release trains used to do manually: catch bad code before it ships, control exposure separately from deployment, know whether trunk can safely reach production, and know exactly where a hotfix belongs.&lt;/p&gt;

&lt;p&gt;That is the trade this series is really about. Less branch ceremony. More operational honesty. Faster feedback. Fewer release superstitions.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;There's a fifth piece coming eventually — testing an OTA update against the real production binary before any real user sees it, without a second build. That's a post of its own once it's actually shipped.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>cicd</category>
      <category>testing</category>
      <category>featureflags</category>
    </item>
    <item>
      <title>Why We Ditched Git-Flow for Trunk-Based Mobile Releases</title>
      <dc:creator>Soham Mondal</dc:creator>
      <pubDate>Tue, 25 Aug 2026 17:49:59 +0000</pubDate>
      <link>https://dev.to/sohammondal/why-we-ditched-git-flow-for-trunk-based-mobile-releases-10e6</link>
      <guid>https://dev.to/sohammondal/why-we-ditched-git-flow-for-trunk-based-mobile-releases-10e6</guid>
      <description>&lt;p&gt;If you've shipped a React Native app on git-flow, you know the tax. &lt;code&gt;develop&lt;/code&gt;, &lt;code&gt;release/x.y.z&lt;/code&gt;, a hotfix branch nobody merges back cleanly, a changelog somebody writes half-awake before store submission, and a release manager doing branch archaeology to answer a simple question: "what is actually in production right now?"&lt;/p&gt;

&lt;p&gt;Git-flow did not break for us because branching theory is flawed. It broke because mobile release safety was being enforced by memory, rituals, and good intentions. That is manageable right up until you're moving fast, juggling OTA updates, store binaries, native dependencies, and the very real fact that Apple does not care that your branches looked tidy.&lt;/p&gt;

&lt;p&gt;We moved our React Native app to trunk-based development. Everything lands on &lt;code&gt;main&lt;/code&gt;. No long-lived &lt;code&gt;develop&lt;/code&gt;. No babysitting &lt;code&gt;release/*&lt;/code&gt; branches. No merge-back ceremony after a hotfix. But the important part is not "fewer branches." The important part is that the decision of whether a commit becomes an OTA update or a full store release is not made by a human anymore. Our release orchestrator makes that call after checking the app's native fingerprint.&lt;/p&gt;

&lt;p&gt;That, to me, is what a mature Expo/React Native release setup looks like: one trunk, boring release mechanics, and explicit guardrails around the native/runtime boundary.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Git-flow vs trunk-based, in one line&lt;/strong&gt;&lt;br&gt;
Git-flow tries to create safety with branch structure. Trunk-based development tries to create safety with fast integration and stronger automation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OTA update&lt;/strong&gt;&lt;br&gt;
In Expo/EAS Update terms, OTA means shipping JavaScript and assets to already-installed binaries without waiting for App Store review. It cannot change native code, native dependencies, permissions, or anything else baked into the binary. &lt;a href="https://docs.expo.dev/eas-update/introduction/" rel="noopener noreferrer"&gt;Expo's docs&lt;/a&gt; are clear on that boundary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1n2imn25q9gpxg2yt9qo.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1n2imn25q9gpxg2yt9qo.jpg" alt="Someone juggling too many plates, one about to fall" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This shift was partly opinionated and partly practical. I had asked peers in the Expo community what they were doing for branching strategy, and the answers were all over the place: some still used release branches, some shipped almost everything from &lt;code&gt;main&lt;/code&gt;, some had custom hybrids built around EAS Update and hotfix pressure. That thread reinforced something I already suspected: there isn't one blessed branch model for React Native. But there is a pattern. Teams that get good at shipping keep removing manual release decisions.&lt;/p&gt;

&lt;p&gt;For context, that discussion is here: &lt;a href="https://www.reddit.com/r/expo/comments/1m6pjkb/whats_your_teams_branching_strategy_for_react/" rel="noopener noreferrer"&gt;What's your team's branching strategy for React Native / Expo?&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea that makes this work
&lt;/h2&gt;

&lt;p&gt;Mobile has a problem web does not: most of your code can update instantly over the air, but some of it is physically baked into a binary already sitting in the App Store or Play Store. Once that binary is installed, there is no clever branching strategy that lets you OTA your way around a native mismatch.&lt;/p&gt;

&lt;p&gt;So the whole system rests on one question, asked by a machine, not by a human in a release Slack channel:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Does this code produce the same native fingerprint as what's already in the store?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every native-relevant input gets hashed into a fingerprint: native dependencies, config plugins, platform config, and other inputs that affect the runtime baked into the app. Expo has been pushing this model pretty hard, and rightly so. If you use &lt;code&gt;runtimeVersion&lt;/code&gt; with the fingerprint policy, that compatibility boundary is no longer a hand-maintained version string. It is derived from the actual native shape of the app.&lt;/p&gt;

&lt;p&gt;That means the release system does not need somebody to eyeball a PR and say, "eh, this seems JS-only." It can compute the answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Native fingerprint&lt;/strong&gt;&lt;br&gt;
Expo Fingerprint is effectively a hash of the app's native-relevant surface area. If that hash changes, the binary and update may no longer be compatible. See the &lt;a href="https://docs.expo.dev/versions/latest/sdk/fingerprint/" rel="noopener noreferrer"&gt;Expo Fingerprint docs&lt;/a&gt; and &lt;a href="https://docs.expo.dev/eas-update/runtime-versions/" rel="noopener noreferrer"&gt;runtime version docs&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;eas fingerprint:compare &lt;span class="nt"&gt;--build&lt;/span&gt; &amp;lt;latest-production-build-id&amp;gt;
✔ Fingerprints match
No native changes detected &lt;span class="k"&gt;for &lt;/span&gt;this runtime.

&lt;span class="nv"&gt;$ &lt;/span&gt;eas fingerprint:compare &lt;span class="nt"&gt;--build&lt;/span&gt; &amp;lt;latest-production-build-id&amp;gt;
✖ Fingerprints &lt;span class="k"&gt;do &lt;/span&gt;not match
Changed sources:
  + app.config.ts
  + ios/Podfile.lock
  + android/gradle/libs.versions.toml

This update needs a new binary before it can ship safely.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That diff is the useful bit. Not just "hash mismatch, good luck," but the actual sources that changed. That is the difference between a five-minute investigation and an hour of people arguing whether a dependency bump really changed the runtime.&lt;/p&gt;

&lt;p&gt;A few examples of changes that look innocent until they aren't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding a package that includes a native module.&lt;/li&gt;
&lt;li&gt;Changing an Expo config plugin.&lt;/li&gt;
&lt;li&gt;Bumping an SDK that rewrites native build files.&lt;/li&gt;
&lt;li&gt;Touching permissions, entitlements, or platform-specific build config.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In git-flow, somebody has to remember those rules. In a mature Expo pipeline, the orchestrator enforces them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyuuzsvwwpf8t51pj7a2v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyuuzsvwwpf8t51pj7a2v.jpg" alt="A friendly computer politely explaining why the fingerprint check failed" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  OTA vs. full release, decided by the orchestrator
&lt;/h2&gt;

&lt;p&gt;A release request should not ask a human to choose between &lt;code&gt;ota&lt;/code&gt; and &lt;code&gt;full-release&lt;/code&gt;. That's exactly the decision humans get wrong under pressure.&lt;/p&gt;

&lt;p&gt;The better model is simple: trigger one release workflow, let it inspect the current state, then route to the correct path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;decide-release-mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.decide.outputs.mode }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;expo/expo-github-action@v8&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;eas-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latest&lt;/span&gt;
          &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.EXPO_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;decide&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;if eas fingerprint:compare --build "$LATEST_PROD_BUILD_ID" --non-interactive; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "mode=ota" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;echo "mode=full-release" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

  &lt;span class="na"&gt;ota&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;decide-release-mode&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.decide-release-mode.outputs.mode == 'ota' }}&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eas update --branch production --message "$(git log -1 --pretty=%s)"&lt;/span&gt;

  &lt;span class="na"&gt;full-release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;decide-release-mode&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.decide-release-mode.outputs.mode == 'full-release' }}&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eas build --platform all --profile production --non-interactive&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eas submit --platform all --latest --non-interactive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcoq0nfvuyr5d1mfkhxvx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcoq0nfvuyr5d1mfkhxvx.jpg" alt="main splitting into an OTA path and a native release path" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is not meant to be copy-paste production YAML. It is the architectural point: one release intent goes in, the workflow decides the path.&lt;/p&gt;

&lt;p&gt;If the fingerprint matches the latest production binary, ship OTA in minutes. If it doesn't, cut a real binary and submit it. Same trigger, different outcome, machine-decided.&lt;/p&gt;

&lt;p&gt;This is also easier to explain to the team. Engineers do not need a release-mode flowchart in their heads. They need one rule: merge to &lt;code&gt;main&lt;/code&gt;, and let the orchestrator classify the change.&lt;/p&gt;

&lt;p&gt;That approach lines up with where Expo/EAS has been heading. EAS Update, EAS Build, runtime versioning, and fingerprint-aware compatibility are all steering toward the same idea: stop treating release safety as tribal knowledge. Encode it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens on a release
&lt;/h2&gt;

&lt;p&gt;The best release systems are boring in the right places. Nobody should be hand-editing version numbers or typing release notes into a markdown file five minutes before submit.&lt;/p&gt;

&lt;p&gt;When a release is triggered, the pipeline should do roughly this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cut a release PR that bumps the app version and generates a changelog from commit history.&lt;/li&gt;
&lt;li&gt;Merge that PR, tag the commit, and build/submit from that exact revision.&lt;/li&gt;
&lt;li&gt;Pin a snapshot branch at the shipped commit so "what is in production" is a Git ref, not a memory exercise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That &lt;code&gt;store/1.24.0+512&lt;/code&gt; branch is not a long-lived release branch. It is a frozen pointer to the exact commit that became the store binary.&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;In git-flow, &lt;code&gt;release/*&lt;/code&gt; branches tend to become semi-alive places where extra fixes, cherry-picks, and "just this one last thing" changes accumulate. In a trunk-based mobile setup, the snapshot branch should stay dumb. It exists so that when production breaks, you know exactly where to branch the hotfix from.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Snapshot branch&lt;/strong&gt;&lt;br&gt;
A snapshot branch is just a named Git ref pinned to the exact shipped commit. It is not a second lane of active development. Its job is traceability and safe hotfix routing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That means a hotfix flow becomes much less ambiguous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the bug affects the live store binary, branch from the latest store snapshot.&lt;/li&gt;
&lt;li&gt;If it's OTA-compatible, the orchestrator can publish it quickly once merged.&lt;/li&gt;
&lt;li&gt;If it changes the native fingerprint, the orchestrator forces the full-release path.&lt;/li&gt;
&lt;li&gt;Either way, &lt;code&gt;main&lt;/code&gt; remains the source of truth, not a maze of partially-synced branches.
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A1(( )) --&amp;gt; A2(( )) --&amp;gt; A3([Release commit]) --&amp;gt; A4(( )) --&amp;gt; A5(( ))
    A3 --&amp;gt; S[store/1.24.0+512 snapshot]
    S --&amp;gt; H{Hotfix needed?}
    H --&amp;gt;|Yes| P[Patch from snapshot]
    P --&amp;gt; M[Merge forward into main]

    classDef commit fill:#fffaf2,stroke:#171717,color:#171717,stroke-width:2px;
    classDef release fill:#f4e1bd,stroke:#171717,color:#171717,stroke-width:2px;
    classDef snapshot fill:#efe6d8,stroke:#171717,color:#171717,stroke-width:2px;
    classDef action fill:#f7f1e8,stroke:#171717,color:#171717,stroke-width:2px;
    classDef accent fill:#f0b35f,stroke:#171717,color:#171717,stroke-width:2px;

    class A1,A2,A4,A5 commit;
    class A3 release;
    class S snapshot;
    class H action;
    class P,M accent;&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This is the part people often miss when they hear "trunk-based for mobile." They imagine recklessness. In practice, a mature setup is more constrained than git-flow. Fewer branches, yes. More automation, definitely. But also stricter compatibility checks, clearer release provenance, and much less room for subjective judgment calls.&lt;/p&gt;

&lt;p&gt;If you're building on Expo today, it's worth reading the official EAS Update docs and watching the work the Expo team is doing around runtime versions, updates, and workflows. The release maturity story is getting better because the underlying primitives are getting better.&lt;/p&gt;

&lt;p&gt;The Expo blog has had a few solid posts in this area too. David Davidov's write-up on Posh's move toward continuous delivery is worth a read, and Jacob Clausen's post on app variants is adjacent in a useful way once you start thinking seriously about release lanes, environments, and binary identity.&lt;/p&gt;

&lt;p&gt;Also, credit where it's due: people building in the Expo/EAS space have made this conversation more concrete over the last couple of years. The tooling is better, the operational model is clearer, and mobile teams finally have a credible path away from branch-heavy release rituals.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sohammondal.com/blog/how-we-keep-a-trunk-based-pipeline-from-being-reckless" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; is where the rest of the safety story picks up: test gates, feature-flag discipline, and hotfix routing without turning &lt;code&gt;main&lt;/code&gt; into a panic lane.&lt;/p&gt;

&lt;p&gt;Further reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.expo.dev/eas-update/introduction/" rel="noopener noreferrer"&gt;EAS Update intro&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.expo.dev/eas-update/runtime-versions/" rel="noopener noreferrer"&gt;Runtime versions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.expo.dev/eas/workflows/introduction/" rel="noopener noreferrer"&gt;EAS Workflows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://expo.dev/blog/posh-manual-weekly-releases-to-continuous-delivery-with-expo" rel="noopener noreferrer"&gt;How Posh went from manual weekly releases to continuous delivery with Expo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://expo.dev/blog/app-variants-side-by-side" rel="noopener noreferrer"&gt;Install dev and production side by side with app variants&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Next: &lt;a href="https://sohammondal.com/blog/how-we-keep-a-trunk-based-pipeline-from-being-reckless" rel="noopener noreferrer"&gt;how we keep a pipeline this fast from being reckless&lt;/a&gt; — the test gate, feature-flag discipline, and the hotfix routing logic that decides exactly which branch a fix belongs on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>cicd</category>
      <category>expo</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How to setup 2 separate Git profiles on a single device?</title>
      <dc:creator>Soham Mondal</dc:creator>
      <pubDate>Thu, 09 Apr 2020 16:27:26 +0000</pubDate>
      <link>https://dev.to/sohammondal/how-to-setup-2-separate-git-profiles-on-a-single-device-5b95</link>
      <guid>https://dev.to/sohammondal/how-to-setup-2-separate-git-profiles-on-a-single-device-5b95</guid>
      <description>&lt;p&gt;I work on a single device and use it for both my office work and side/pet/personal projects. Default git credentials for the device is obviously the one provided by my office. Now, this scenario has happened to me a lot of times - &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My office work is done&lt;/li&gt;
&lt;li&gt;I am super excited about a personal project idea&lt;/li&gt;
&lt;li&gt;So I set up a remote git repository&lt;/li&gt;
&lt;li&gt;Set up the project boilerplate locally&lt;/li&gt;
&lt;li&gt;Fire &lt;code&gt;git init&lt;/code&gt;, &lt;code&gt;git remote add&lt;/code&gt;, &lt;code&gt;git add&lt;/code&gt;, &lt;code&gt;git commit&lt;/code&gt; &amp;amp; PUSH!!!&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;And it is then when I realize I have pushed code to my personal git with wrong (office) credentials. And I start all over again. &lt;/p&gt;

&lt;p&gt;Do let me know if you have ever faced a similar situation like mine in comments!&lt;/p&gt;

&lt;p&gt;So, now what is the solution? How do I have separate git config on a single machine? The answer is pretty simple. You need to have two separate Git config files. But, how ? Read on...&lt;/p&gt;

&lt;p&gt;*** &lt;b&gt;Before starting make sure you have git version 2.13 or higher installed in your system.&lt;/b&gt; ***&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;--version&lt;/span&gt;
git version 2.14.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now with the prerequisites set, let's begin -&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your &lt;code&gt;.gitconfig&lt;/code&gt; resides in your home directory. For Windows users, it will be in the path &lt;code&gt;C:\Users\[YOUR_NAME]&lt;/code&gt;. Go to that directory.&lt;/li&gt;
&lt;li&gt;Create two separate folders like &lt;em&gt;My_Office_Workspace&lt;/em&gt; &amp;amp; &lt;em&gt;My_Personal_Workspace&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Create two separate &lt;code&gt;.gitconfig&lt;/code&gt; files, one for office and another for personal usage in the path &lt;code&gt;C:\Users\[YOUR_NAME]&lt;/code&gt;. Let's name them &lt;code&gt;.gitconfig-office&lt;/code&gt; &amp;amp; &lt;code&gt;.gitconfig-personal&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The contents of &lt;code&gt;.gitconfig-office&lt;/code&gt; -&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# This is Git's per-user configuration file.&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;user]
name &lt;span class="o"&gt;=&lt;/span&gt; YOUR_OFFICE_GIT_USERNAME
email &lt;span class="o"&gt;=&lt;/span&gt; your.name@company.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contents of &lt;code&gt;.gitconfig-personal&lt;/code&gt; -&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# This is Git's per-user configuration file.&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;user]
name &lt;span class="o"&gt;=&lt;/span&gt; YOUR_PERSONAL_GIT_USERNAME
email &lt;span class="o"&gt;=&lt;/span&gt; your.name@gmail.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;After that's done, open up the &lt;code&gt;.gitconfig&lt;/code&gt; file, and make the necessary changes. It should look like -
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;includeIf &lt;span class="s2"&gt;"gitdir:~/My_Office_Workspace/"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
  path &lt;span class="o"&gt;=&lt;/span&gt; .gitconfig-office
&lt;span class="o"&gt;[&lt;/span&gt;includeIf &lt;span class="s2"&gt;"gitdir:~/My_Personal_Workspace/"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
  path &lt;span class="o"&gt;=&lt;/span&gt; .gitconfig-personal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configuration done! Now let's verify.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Goto &lt;em&gt;My_Office_Workspace&lt;/em&gt; and clone a project from your office GitHub, BitBucket or GitLab. Let's call the project &lt;em&gt;My_Office_Project&lt;/em&gt; and do the following -
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;My_Office_Workspace/
&lt;span class="nv"&gt;$ &lt;/span&gt;git clone https://github.my_office.com/VeryImportant/My_Office_Project.git
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;My_Office_Project/
&lt;span class="nv"&gt;$ &lt;/span&gt;git config user.name
YOUR_OFFICE_GIT_USERNAME
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;You can also do the same for your personal projects under &lt;em&gt;My_Perosonal_Workspace&lt;/em&gt;, but you will see you the output for the last command &lt;code&gt;git config user.name&lt;/code&gt; is YOUR_PERSONAL_GIT_USERNAME.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Woohoo, it's done! No more messing up. 😃&lt;/p&gt;

&lt;h3&gt;
  
  
  Update
&lt;/h3&gt;

&lt;p&gt;If your projects are in a different drive on Windows, for eg. D or F, then you can configure the &lt;code&gt;.gitconfig&lt;/code&gt; like this&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;includeIf &lt;span class="s2"&gt;"gitdir:D:/My_Office_Workspace/"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
  path &lt;span class="o"&gt;=&lt;/span&gt; .gitconfig-office
&lt;span class="o"&gt;[&lt;/span&gt;includeIf &lt;span class="s2"&gt;"gitdir:F:/My_Personal_Workspace/"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
  path &lt;span class="o"&gt;=&lt;/span&gt; .gitconfig-personal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Liked this post? Share it ↩️ or maybe give a heart 🧡. &lt;/p&gt;

&lt;p&gt;I am &lt;a href="https://sohammondal.com" rel="noopener noreferrer"&gt;Soham Mondal&lt;/a&gt;, a developer from India. You can follow me on &lt;a href="https://www.linkedin.com/in/soham-sm-mondal/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, &lt;a href="https://twitter.com/sohammondal_" rel="noopener noreferrer"&gt;Instagram&lt;/a&gt;, or &lt;a href="https://github.com/sohammondal/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. You can learn more about me on my &lt;a href="https://sohammondal.com/about" rel="noopener noreferrer"&gt;website&lt;/a&gt;. I am currently open to new projects &amp;amp; job offers.&lt;/p&gt;

</description>
      <category>git</category>
      <category>github</category>
      <category>bash</category>
      <category>hack</category>
    </item>
  </channel>
</rss>
