<?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: Maksym Kuzmitskyi (MaximusFT)</title>
    <description>The latest articles on DEV Community by Maksym Kuzmitskyi (MaximusFT) (@maximusft).</description>
    <link>https://dev.to/maximusft</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%2F1819104%2Fd158bf22-3ff5-498a-bab8-91ce4b684bc1.jpg</url>
      <title>DEV Community: Maksym Kuzmitskyi (MaximusFT)</title>
      <link>https://dev.to/maximusft</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maximusft"/>
    <language>en</language>
    <item>
      <title>Barrel Files: The Import You Love and the Bundle You Hate</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/barrel-files-the-import-you-love-and-the-bundle-you-hate-49gh</link>
      <guid>https://dev.to/maximusft/barrel-files-the-import-you-love-and-the-bundle-you-hate-49gh</guid>
      <description>&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%2Fpomv94fcng5gvzx8o7jv.png" 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%2Fpomv94fcng5gvzx8o7jv.png" alt="Barrel Files: The Import You Love and the Bundle You Hate" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a specific kind of import that feels like good engineering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Modal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Tooltip&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. One path. Clean. Compare it to the honest version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/ui/Button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/ui/Card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Modal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/ui/Modal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Tooltip&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/ui/Tooltip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first one looks like a tidy public API. The second looks like clutter. So of course teams reach for the first — they add an &lt;code&gt;index.ts&lt;/code&gt; that re-exports everything, and the whole folder gets a single front door. That file is a &lt;em&gt;barrel&lt;/em&gt;, and it's one of the most quietly expensive habits in a React codebase. Not because it's wrong. Because it's right just often enough that people stop questioning it.&lt;/p&gt;

&lt;p&gt;I brought this up at the end of the &lt;a href="https://ma-x.im/blog/react-playbook-shared-code" rel="noopener noreferrer"&gt;shared/ article&lt;/a&gt; and promised to come back to it. Here's the come-back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a barrel actually is
&lt;/h2&gt;

&lt;p&gt;A barrel is just a file — almost always &lt;code&gt;index.ts&lt;/code&gt; — whose only job is to re-export other modules:&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;// shared/ui/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Button&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Card&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Modal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Modal&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Tooltip&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Tooltip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;@/shared/ui&lt;/code&gt; resolves to this file, and consumers import named things from one place. The appeal is real: imports get shorter, the folder gets a defined surface, and you can move &lt;code&gt;Button.tsx&lt;/code&gt; around internally without breaking any consumer, as long as the barrel keeps pointing at the right place. That last part — decoupling the internal file layout from the public import path — is a genuine architectural benefit, not just cosmetics. It's the same "public API of a folder" idea that makes feature boundaries work.&lt;/p&gt;

&lt;p&gt;So far this sounds like something you'd want everywhere. That's exactly the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost hides where you're not looking
&lt;/h2&gt;

&lt;p&gt;Here's the problem. When you write &lt;code&gt;import { Button } from '@/shared/ui'&lt;/code&gt;, you are not importing &lt;code&gt;Button&lt;/code&gt;. You are importing the &lt;em&gt;barrel&lt;/em&gt;, and the barrel imports &lt;strong&gt;everything&lt;/strong&gt;. &lt;code&gt;Button&lt;/code&gt;, &lt;code&gt;Card&lt;/code&gt;, &lt;code&gt;Modal&lt;/code&gt;, &lt;code&gt;Tooltip&lt;/code&gt;, and whatever those pull in — the whole folder loads to hand you one component.&lt;/p&gt;

&lt;p&gt;"But tree-shaking removes the unused ones," you say. Sometimes. Tree-shaking is real, but it's fragile, and a barrel is exactly the shape that breaks it. All it takes is one module in the barrel with a side effect — a file that runs code at import time, registers something, mutates a global, or is marked as having side effects in &lt;code&gt;package.json&lt;/code&gt; — and the bundler can no longer prove the other exports are safe to drop. To stay correct, it keeps them. Your one-line &lt;code&gt;Button&lt;/code&gt; import just pulled in &lt;code&gt;Modal&lt;/code&gt;, its focus-trap library, and the animation dependency &lt;code&gt;Tooltip&lt;/code&gt; uses.&lt;/p&gt;

&lt;p&gt;You won't see it in code review. The import line looks identical whether the tree shakes cleanly or not. You'll see it in a bundle analyzer six months later, staring at a route that weighs 400KB and wondering why a page with two buttons is importing a charting library. The answer is almost always a barrel somewhere in the chain, quietly gluing unrelated things together.&lt;/p&gt;

&lt;p&gt;There's a second cost that shows up sooner: build and dev-server speed. Every barrel is a fan-out. Import one thing through &lt;code&gt;@/shared/ui&lt;/code&gt; and the module graph has to resolve every file the barrel touches, and every file &lt;em&gt;those&lt;/em&gt; touch. In a big app with barrels re-exporting barrels re-exporting barrels, you get a dependency graph that explodes on almost any import. Cold dev starts crawl. Hot updates touch more than they should. The tooling spends its time walking a graph you created for the sake of a shorter import line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A barrel trades a real cost you can't see for a cosmetic benefit you can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The worst offender: the app-wide barrel
&lt;/h2&gt;

&lt;p&gt;The pattern that does the most damage is the ambition to have one barrel at a high level that re-exports a whole layer:&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;// features/index.ts — please don't&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./edit-policy&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./create-claim&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./billing-dashboard&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user-settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...twenty more&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;import { EditPolicyForm } from '@/features'&lt;/code&gt; reaches into a barrel that transitively imports &lt;em&gt;every feature in the app&lt;/em&gt;. Import one feature and you've referenced all of them. Code-splitting — the entire point of which is that a route loads only what it needs — quietly stops working, because from the bundler's view everything is connected to everything through this one file. I've watched a lazy-loaded route pull in the whole app because a single import went through a top-level barrel instead of reaching for the specific module.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;export *&lt;/code&gt; form is especially nasty. It's not just eager; it forces the bundler to load a module just to find out what names it exports, so it can figure out which star-export a given name came from. Named re-exports (&lt;code&gt;export { X } from './x'&lt;/code&gt;) are at least explicit. &lt;code&gt;export *&lt;/code&gt; is a blank check.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a barrel earns its place
&lt;/h2&gt;

&lt;p&gt;I'm not anti-barrel. I use them. The question is never "barrels: yes or no." It's "does this specific barrel pay for itself." A barrel earns its place when three things are true at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It wraps a genuine, stable public API.&lt;/strong&gt; A design-system package where &lt;code&gt;Button&lt;/code&gt;, &lt;code&gt;Card&lt;/code&gt;, and &lt;code&gt;Modal&lt;/code&gt; are &lt;em&gt;the whole point&lt;/em&gt; and consumers legitimately pull from across it — that's a real interface, and a barrel is the right way to express it. The barrel isn't hiding a mess; it's naming a surface that was always meant to be used as a whole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's small and internally cohesive.&lt;/strong&gt; A barrel over five related components in one feature is cheap — importing one probably means you're near the others anyway, and the fan-out is tiny. The cost of a barrel scales with what's behind it. Small folder, small cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing behind it has surprising weight or side effects.&lt;/strong&gt; Pure, light modules tree-shake fine through a barrel. The danger is heavy or effectful modules — a component that drags in a 200KB dependency, a file that runs setup at import. One of those behind a barrel poisons every import that passes through it.&lt;/p&gt;

&lt;p&gt;Miss any of those and the barrel is working against you. A top-level &lt;code&gt;@/features&lt;/code&gt; barrel fails all three: not a stable surface, not small, and full of heavy things. A &lt;code&gt;shared/ui/index.ts&lt;/code&gt; over a real design system passes all three. Same mechanism, opposite verdict — which is the whole reason "always use barrels" and "never use barrels" are both wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually do
&lt;/h2&gt;

&lt;p&gt;My rules are boring, which is how I like architecture rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Import from the specific file by default.&lt;/strong&gt; &lt;code&gt;import { Button } from '@/shared/ui/Button'&lt;/code&gt; is my normal. The import is three words longer and I never think about it again. No fan-out, no tree-shaking gamble, no accidental coupling.&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;// default: reach for the exact thing&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/ui/Button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;formatCurrency&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/shared/lib/money&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Add a barrel only at a real boundary I've decided to publish.&lt;/strong&gt; A feature's public entry point, a design-system package's root — places where "this is the surface, use it as one" is a deliberate architectural statement, not a convenience. And when I do, I write explicit named re-exports, never &lt;code&gt;export *&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// features/edit-policy/index.ts — a deliberate public surface&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;EditPolicyForm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./ui/EditPolicyForm&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEditPolicy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./model/useEditPolicy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// internal helpers are NOT re-exported — they stay private&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That barrel is doing architectural work: it defines what the feature exposes and hides everything else, so the rest of the app can't reach into the feature's guts. That's a barrel as an &lt;em&gt;encapsulation boundary&lt;/em&gt;, which is worth having. A barrel as a &lt;em&gt;typing convenience&lt;/em&gt; usually isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never barrel a whole layer.&lt;/strong&gt; No &lt;code&gt;@/features&lt;/code&gt;, no &lt;code&gt;@/entities&lt;/code&gt; mega-barrel. Layers are not modules; they're categories. Giving a category a single import door is how code-splitting dies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify with the analyzer, not with faith.&lt;/strong&gt; Tree-shaking is a claim, not a guarantee. Run a bundle analyzer, find the route that's too heavy, and trace it. More often than not the culprit is a barrel doing exactly what I described — and the fix is changing one import from the barrel to the specific file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson isn't about barrels
&lt;/h2&gt;

&lt;p&gt;Strip away the mechanics and this is the same idea as the &lt;code&gt;shared/&lt;/code&gt; article, pointed at a different symptom. A barrel is a public API for a folder. Public APIs are worth having when there's a real boundary to protect and a real surface to name. They're pure cost when you add them out of habit, to make imports prettier, over a pile of things that were never meant to be used as a unit.&lt;/p&gt;

&lt;p&gt;So the question to ask isn't "should this folder have an index.ts." It's "does this folder have a public API worth defining — and am I willing to pay the fan-out to define it." When the answer is yes, barrel it, name the surface, and hide the rest. When the answer is "I just want shorter imports," reach for the specific file and move on. The prettier import was never worth the bundle you couldn't see.&lt;/p&gt;

&lt;p&gt;Next in this stretch on architecture, I want to get at the thing underneath both of these articles: &lt;a href="https://ma-x.im/blog/react-playbook-module-dependencies" rel="noopener noreferrer"&gt;how modules should &lt;em&gt;depend&lt;/em&gt; on each other&lt;/a&gt; at all — the direction of the arrows, who's allowed to import whom, and why a dependency graph is the most honest picture of your app's health. If you've got a bundle that mysteriously balloons on one route, open the analyzer and go hunting for a barrel — then tell me what you found. I'm always curious how far the fan-out reached.&lt;/p&gt;

</description>
      <category>react</category>
      <category>architecture</category>
      <category>performance</category>
      <category>reactplaybook</category>
    </item>
    <item>
      <title>The shared/ Folder Is Not a Place, It's a Promise</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/the-shared-folder-is-not-a-place-its-a-promise-1d7b</link>
      <guid>https://dev.to/maximusft/the-shared-folder-is-not-a-place-its-a-promise-1d7b</guid>
      <description>&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%2F08civi14eiufnaa9d5fr.png" 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%2F08civi14eiufnaa9d5fr.png" alt="The shared/ Folder Is Not a Place, It's a Promise" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open the &lt;code&gt;shared/&lt;/code&gt; folder in almost any React app that's been alive for a year. Count the files. Then try to explain, out loud, what the folder is &lt;em&gt;for&lt;/em&gt; — not what's in it, what it's &lt;em&gt;for&lt;/em&gt;. You'll stall. There's a date formatter, sure, and a Button. But there's also &lt;code&gt;useUserPermissions&lt;/code&gt;, a &lt;code&gt;formatPolicyNumber&lt;/code&gt;, half a checkout state machine, a &lt;code&gt;legacyApiAdapter&lt;/code&gt;, and three hooks whose names start with &lt;code&gt;use&lt;/code&gt; and end in a shrug.&lt;/p&gt;

&lt;p&gt;That folder didn't get that way because someone was careless. It got that way because &lt;code&gt;shared/&lt;/code&gt; is the one folder in your app with no rule attached to it. Everything else has a reason to reject a file. &lt;code&gt;shared/&lt;/code&gt; accepts everything. And a folder that accepts everything isn't a category — it's a drain.&lt;/p&gt;

&lt;p&gt;I already flagged this in the &lt;a href="https://ma-x.im/blog/react-playbook-code-structure" rel="noopener noreferrer"&gt;code structure article&lt;/a&gt;: &lt;code&gt;shared/&lt;/code&gt; is the most abused folder in React architecture. That piece told you the rule. This one is about why the rule is so hard to keep, and how to dig out once you've already lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the drawer fills up
&lt;/h2&gt;

&lt;p&gt;The junk drawer isn't a discipline problem. If it were, telling people "be more careful about &lt;code&gt;shared/&lt;/code&gt;" would fix it. It never does. The rot is structural, and it comes from three forces that all push the same direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The naming trap.&lt;/strong&gt; You write a hook. Where does it go? If it has an obvious owner — &lt;code&gt;features/edit-policy&lt;/code&gt; — the answer is easy. But a lot of code sits in the awkward middle: not clearly owned, not clearly generic. &lt;code&gt;useDebouncedValue&lt;/code&gt; is generic. &lt;code&gt;usePolicyDraftAutosave&lt;/code&gt; is owned. But &lt;code&gt;useAutosave&lt;/code&gt;, the one that &lt;em&gt;started&lt;/em&gt; generic and quietly grew a &lt;code&gt;policyId&lt;/code&gt; parameter — that one has no obvious home. So it goes to &lt;code&gt;shared/&lt;/code&gt;, because &lt;code&gt;shared/&lt;/code&gt; never says no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reuse reflex.&lt;/strong&gt; The moment a second file wants something, the instinct is to "lift it up" so both can reach it. This feels like good engineering. It's usually premature. Two components in the same feature using one helper is not a reason to move that helper three layers up into &lt;code&gt;shared/lib&lt;/code&gt; — it's a reason to keep it &lt;em&gt;inside the feature&lt;/em&gt;. Reuse across a boundary is a real signal. Reuse within a boundary is just normal code living near the thing that uses it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The path of least resistance.&lt;/strong&gt; &lt;code&gt;shared/&lt;/code&gt; is at the bottom of the dependency graph, so everything can import from it. That makes it the safest place to drop something when you're in a hurry and don't want to think about ownership. No import will ever break. And that's exactly the problem: the folder that's safe to import from is also the folder that's frictionless to dump into.&lt;/p&gt;

&lt;p&gt;Put those three together and you don't get a junk drawer because people are bad at their jobs. You get one because the folder is doing precisely what an unconstrained folder does under pressure. Delivery pressure always wins over categorization, and &lt;code&gt;shared/&lt;/code&gt; is where "I'll sort it later" goes to never be sorted.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;shared/&lt;/code&gt; is not a place you put things. It's a promise you make about them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That reframing is the whole article. The promise is: &lt;em&gt;this code is domain-agnostic and portable — it would make sense in a completely different product.&lt;/em&gt; Every file in &lt;code&gt;shared/&lt;/code&gt; is you signing that promise. The drawer fills up because people put things there without noticing they're signing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that actually works
&lt;/h2&gt;

&lt;p&gt;The rule I gave in the structure article still holds, and it's the only test I've found that survives contact with a real team:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A file belongs in &lt;code&gt;shared/&lt;/code&gt; only if you could move it to a &lt;em&gt;different product at the same company&lt;/em&gt; and it would still make sense.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A &lt;code&gt;Button&lt;/code&gt;? Moves fine. A &lt;code&gt;formatCurrency&lt;/code&gt;? Moves fine. An &lt;code&gt;http&lt;/code&gt; client with your auth interceptor? Borderline — it moves if the auth scheme is company-wide, stays if it's app-specific. A &lt;code&gt;useUserPermissions&lt;/code&gt; hook that knows your permission strings? Doesn't move. It's dressed up as generic — it takes a permission key, it returns a boolean, it &lt;em&gt;looks&lt;/em&gt; reusable — but it's welded to your domain's idea of what a permission is. That's not shared code. That's domain code wearing a generic costume.&lt;/p&gt;

&lt;p&gt;Here's the tell, in code. This looks shared:&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;// shared/hooks/usePermission.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;usePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCurrentUser&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;permissions&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;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It isn't. &lt;code&gt;useCurrentUser&lt;/code&gt; is a domain concept. The moment this hook reaches sideways into &lt;code&gt;entities/user&lt;/code&gt;, it stopped being portable — it now depends on &lt;em&gt;your&lt;/em&gt; app's notion of a user. Move it to a different product and it breaks. It belongs in &lt;code&gt;entities/user&lt;/code&gt;, exported as part of that entity's public surface. What's left in &lt;code&gt;shared/&lt;/code&gt; should be the genuinely dumb primitive:&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;// shared/lib/includes.ts — portable, knows nothing&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;hasKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keys&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;keys&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;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The portability test isn't about whether code &lt;em&gt;can&lt;/em&gt; be reused. Almost anything can be reused if you squint. It's about whether the code &lt;em&gt;knows about your domain&lt;/em&gt;. Knowledge is the boundary, not usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the boundary a wall, not a suggestion
&lt;/h2&gt;

&lt;p&gt;A rule that lives only in a code review is a rule you will lose. Reviewers get tired. New people don't know it. The pressure that fills the drawer doesn't take weekends off. So the direction of dependencies — lower layers never importing from higher ones — has to be enforced by tooling, not vigilance.&lt;/p&gt;

&lt;p&gt;ESLint's &lt;code&gt;no-restricted-imports&lt;/code&gt; gets you most of the way with zero extra dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/shared/**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-restricted-imports&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;patterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/features/*&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;@/entities/*&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;@/widgets/*&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;@/pages/*&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;@/app/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="p"&gt;],&lt;/span&gt;
              &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shared/ must not import from higher layers. If this file needs domain code, it does not belong in shared/.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the &lt;code&gt;usePermission&lt;/code&gt; example from earlier fails the build the instant it imports &lt;code&gt;useCurrentUser&lt;/code&gt;. The error message isn't just "no" — it &lt;em&gt;explains the promise&lt;/em&gt;: if this file needs domain code, it doesn't belong here. The lint rule becomes the reviewer who never gets tired and never forgets the rule. For richer needs — enforcing that features can't import each other, that entities stay isolated — a dedicated boundaries plugin does the same job with more expressive rules, but the built-in gets a small team surprisingly far.&lt;/p&gt;

&lt;p&gt;The point isn't the exact config. It's that the promise stops depending on everyone remembering it. You made &lt;code&gt;shared/&lt;/code&gt; say no. That single change is the difference between a folder that stays small and one that quietly triples every quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Digging out of one you already have
&lt;/h2&gt;

&lt;p&gt;Prevention is easy to talk about when the drawer is empty. Most of the time it isn't. You've inherited forty files and a folder that's load-bearing in ways nobody documented. You can't stop shipping features to go on an architecture retreat, and a big-bang "fix shared/" pull request is a merge-conflict machine that reviewers will rubber-stamp out of exhaustion. So don't do that. Dig out incrementally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, audit without moving anything.&lt;/strong&gt; Go through &lt;code&gt;shared/&lt;/code&gt; file by file and tag each one with a comment — three buckets only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Portable&lt;/strong&gt; — passes the test. Leave it. This is what &lt;code&gt;shared/&lt;/code&gt; is &lt;em&gt;for&lt;/em&gt;, and seeing what legitimately qualifies is calming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owned&lt;/strong&gt; — knows about a domain concept. It has a real home in an &lt;code&gt;entity&lt;/code&gt; or a &lt;code&gt;feature&lt;/code&gt;; it's just sitting in the wrong place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split&lt;/strong&gt; — genuinely two things fused together: a portable core with a domain-specific wrapper grown around it. The &lt;code&gt;useAutosave&lt;/code&gt;-that-grew-a-&lt;code&gt;policyId&lt;/code&gt; case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Then move the easy ones.&lt;/strong&gt; The &lt;em&gt;Owned&lt;/em&gt; bucket is pure relocation — cut the file, paste it into its real home, fix the imports, done. Your lint rule from the previous section will actually help here: as you move domain code out, anything still illegally reaching into higher layers lights up red, showing you the next thing to fix. The compiler turns into a to-do list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then split the hard ones.&lt;/strong&gt; For each &lt;em&gt;Split&lt;/em&gt; file, separate the portable core from the domain wrapper. The core stays in &lt;code&gt;shared/&lt;/code&gt; as a dumb primitive. The wrapper moves down to the feature that needs the domain-specific behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before — one file in shared/, quietly domain-aware&lt;/span&gt;
&lt;span class="c1"&gt;// shared/hooks/useAutosave.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useAutosave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;policyId&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;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PolicyDraft&lt;/span&gt;&lt;span class="p"&gt;)&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="c1"&gt;// after — the portable half&lt;/span&gt;
&lt;span class="c1"&gt;// shared/lib/useAutosave.ts — generic, no domain knowledge&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useAutosave&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;save&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// after — the domain half, where it belongs&lt;/span&gt;
&lt;span class="c1"&gt;// features/edit-policy/model/usePolicyAutosave.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;usePolicyAutosave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;policyId&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;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PolicyDraft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useAutosave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`policy:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;policyId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;savePolicyDraft&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;Notice the generic &lt;code&gt;useAutosave&lt;/code&gt; got &lt;em&gt;better&lt;/em&gt; by being forced portable — it now takes its save function as an argument instead of hard-coding &lt;code&gt;savePolicyDraft&lt;/code&gt;, which makes it genuinely reusable for the first time. Enforcing the boundary didn't just tidy the folder. It improved the primitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then delete.&lt;/strong&gt; The satisfying part. Once the &lt;em&gt;Owned&lt;/em&gt; and &lt;em&gt;Split&lt;/em&gt; files are gone, you'll almost always find a handful of files nothing imports anymore — helpers that existed only to serve code that moved. Delete them. A shrinking &lt;code&gt;shared/&lt;/code&gt; is the clearest signal the dig-out is working.&lt;/p&gt;

&lt;p&gt;You don't have to finish in one sitting. Do a bucket a week. The direction matters more than the speed: as long as &lt;code&gt;shared/&lt;/code&gt; is getting smaller and more portable over time instead of larger and vaguer, you're winning.&lt;/p&gt;

&lt;h2&gt;
  
  
  A word on the barrel
&lt;/h2&gt;

&lt;p&gt;While you're in here, you'll be tempted to add an &lt;code&gt;index.ts&lt;/code&gt; that re-exports everything so imports look clean: &lt;code&gt;import { Button, formatCurrency } from '@/shared'&lt;/code&gt;. Resist making that reflexive. A barrel over a genuinely small, stable, portable &lt;code&gt;shared/&lt;/code&gt; is fine. A barrel over the junk drawer just gives the junk drawer a tidy front door — and it can quietly drag your whole &lt;code&gt;shared/&lt;/code&gt; tree into every bundle that touches one export. The clean import is cosmetic; the coupling underneath is real. Fix the contents first. The public API of a folder is only worth having once the folder actually keeps a promise. That trade-off — barrels, bundles, and what a folder's public surface should really be — is a whole topic on its own, and one I want to come back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The folder was never the problem
&lt;/h2&gt;

&lt;p&gt;It's easy to read all this as "&lt;code&gt;shared/&lt;/code&gt; is bad, avoid it." That's the wrong lesson. Every non-trivial app needs a home for genuinely portable code, and &lt;code&gt;shared/&lt;/code&gt; is a perfectly good name for it. The folder was never the enemy.&lt;/p&gt;

&lt;p&gt;The missing promise was. A junk drawer is just a drawer that nobody agreed on the contents of. The fix isn't a better folder name or a stricter reviewer — it's making the promise explicit, and then making a machine enforce it so the promise survives the next deadline. Do that, and &lt;code&gt;shared/&lt;/code&gt; stops being the place your architecture goes to die and goes back to being the boring, dependable bottom of the stack. Boring is the goal. In &lt;code&gt;shared/&lt;/code&gt;, boring is the whole point.&lt;/p&gt;

&lt;p&gt;Next, I want to pick up the barrel thread I brushed past earlier: &lt;a href="https://ma-x.im/blog/react-playbook-barrel-files" rel="noopener noreferrer"&gt;why the tidy &lt;code&gt;index.ts&lt;/code&gt; that gives a folder a clean front door&lt;/a&gt; can quietly drag half your app into a bundle that only needed one function — and when it's still worth it.&lt;/p&gt;

&lt;p&gt;If you've got a &lt;code&gt;shared/&lt;/code&gt; folder you're a little afraid to open — or a war story about one that got completely out of hand — I'd genuinely like to hear it. Reach out; I collect these.&lt;/p&gt;

</description>
      <category>react</category>
      <category>architecture</category>
      <category>fsd</category>
      <category>reactplaybook</category>
    </item>
    <item>
      <title>React in 3D: When &lt;div&gt; Becomes &lt;mesh&gt;</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/react-in-3d-when-becomes-4h0j</link>
      <guid>https://dev.to/maximusft/react-in-3d-when-becomes-4h0j</guid>
      <description>&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%2Fsa0lvxr2yvfo5xfozowc.png" 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%2Fsa0lvxr2yvfo5xfozowc.png" alt="React in 3D: When &lt;div&gt; Becomes &lt;mesh&gt;" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first time someone tells you that you can build a 3D scene — a rotating model, a lit environment, a VR world — using React, it sounds like a category error. React is for interfaces. Buttons, forms, lists. Three-dimensional graphics are a different universe with their own math, their own pipeline, their own everything. Surely gluing them together is a hack.&lt;/p&gt;

&lt;p&gt;It isn't, and the reason it isn't is the perfect note to end this series on. Because after twenty-nine articles about data fetching and forms and auth and desktop and mobile, the thing rendering React in 3D quietly proves is the idea that was underneath all of it the whole time: &lt;strong&gt;React was never really about the DOM.&lt;/strong&gt; React is a system for describing a UI as a function of state and letting a renderer figure out how to make it real. The DOM was just the first and most common target. Point that same system at a 3D scene graph and it works — not as a trick, but because 3D was always within the reach of what React actually is.&lt;/p&gt;

&lt;p&gt;So this last article is short, and it's really about a realization more than a technology. Let me show you what I mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scene Graph Is Just Another Tree
&lt;/h2&gt;

&lt;p&gt;Here's the conceptual key. React renders trees. The DOM is a tree of elements — a &lt;code&gt;div&lt;/code&gt; containing a &lt;code&gt;span&lt;/code&gt; containing text. A 3D scene is &lt;em&gt;also&lt;/em&gt; a tree: a scene contains a mesh, a mesh has a geometry and a material, lights and cameras sit alongside them. Same fundamental shape — a nested hierarchy of things — which means React's component model, built for exactly that shape, fits a 3D scene as naturally as it fits a webpage.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://r3f.docs.pmnd.rs" rel="noopener noreferrer"&gt;React Three Fiber&lt;/a&gt; is the renderer that makes this literal. It's React — real React, same hooks, same state, same composition — where the elements are 3D objects instead of HTML tags. You don't write &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;; you write &lt;code&gt;&amp;lt;mesh&amp;gt;&lt;/code&gt;. You don't nest &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; inside &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt;; you nest a geometry and a material inside a mesh.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Canvas&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@react-three/fiber&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;mesh&lt;/span&gt; &lt;span class="na"&gt;rotation&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="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;boxGeometry&lt;/span&gt; &lt;span class="na"&gt;args&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;meshStandardMaterial&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#22c55e"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;mesh&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Scene&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ambientLight&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;pointLight&lt;/span&gt; &lt;span class="na"&gt;position&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Box&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that and the uncanny thing is how &lt;em&gt;ordinary&lt;/em&gt; it is. It's components. &lt;code&gt;&amp;lt;Box /&amp;gt;&lt;/code&gt; is a component you could reuse, wrap in a list, conditionally render. &lt;code&gt;rotation&lt;/code&gt; and &lt;code&gt;color&lt;/code&gt; are just props — drive them from state and the cube animates or recolors, because it's the same reactivity you've used all series. &lt;code&gt;&amp;lt;mesh&amp;gt;&lt;/code&gt; is to a 3D renderer what &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; is to the DOM renderer: a primitive the renderer knows how to draw. Everything you know about composing React components — props down, state-driven rendering, reuse, hooks — applies unchanged. Only the primitives at the bottom of the tree are different.&lt;/p&gt;

&lt;h2&gt;
  
  
  VR and AR Are Just More of the Same Realization
&lt;/h2&gt;

&lt;p&gt;Once you accept that React can describe a 3D scene, VR and AR stop being a separate leap. A VR headset is, from the code's point of view, a 3D scene rendered in stereo with head tracking. AR is a 3D scene composited over a camera feed. The &lt;em&gt;scene&lt;/em&gt; — the tree of meshes and lights and cameras — is the same tree React Three Fiber already renders; the WebXR layer adds the headset and the tracking on top.&lt;/p&gt;

&lt;p&gt;So the progression is one idea extended, not three separate technologies to learn. Describe a 3D scene as a React tree. Render that tree to a screen — that's 3D on the web. Render it to a headset in stereo — that's VR. Composite it onto the real world — that's AR. At every step the component model is identical; what changes is the output device, exactly the way it changed when we went from browser tab to &lt;a href="https://ma-x.im/blog/react-playbook-desktop" rel="noopener noreferrer"&gt;desktop window&lt;/a&gt; to &lt;a href="https://ma-x.im/blog/react-playbook-react-native" rel="noopener noreferrer"&gt;native phone&lt;/a&gt;. Same React, different target. You are not learning 3D-React and then VR-React and then AR-React. You are learning that React describes a tree, and the tree can be pointed at increasingly immersive places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Thing This Whole Series Was Actually About
&lt;/h2&gt;

&lt;p&gt;I saved this topic for last on purpose, because it makes the through-line of everything before it visible.&lt;/p&gt;

&lt;p&gt;Look back at what we did. We fetched data and let a renderer reconcile the result. We managed state and let components re-render as a function of it. We moved React onto the desktop, onto phones, and now into three-dimensional space — and at every single stop, the &lt;em&gt;same&lt;/em&gt; mental model carried: describe what the UI should be for a given state, and let a renderer make it real on whatever the target happens to be. The target was a browser, then a native window, then a native view, then a scene graph. The model never changed. That's not a coincidence you stumble on at article thirty — it's the thing the whole series was quietly teaching under the cover of specific problems.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;React is not a DOM library. It is a way of describing UI as a function of state, with a renderer that reconciles your description against some target. The DOM was always just the first target.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's why a React developer can move across this entire landscape — web, desktop, mobile, 3D, immersive — without starting over each time. The specifics are real and each one takes genuine learning, as every article in this series insisted. But the foundation is portable in a way that's rare in software, and 3D is the clearest proof: the moment &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; becomes &lt;code&gt;&amp;lt;mesh&amp;gt;&lt;/code&gt; and &lt;em&gt;nothing else about how you think has to change&lt;/em&gt;, you understand what you actually learned. You didn't learn the DOM. You learned React.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It — and Where the Series Lands
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3D isn't a hack on top of React.&lt;/strong&gt; A scene is a tree, React renders trees, and React Three Fiber makes the elements 3D objects. &lt;code&gt;&amp;lt;mesh&amp;gt;&lt;/code&gt; is the new &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your whole component model transfers.&lt;/strong&gt; Props, state, hooks, composition, reuse — all unchanged. Only the leaf primitives differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VR and AR are the same tree, a different output device.&lt;/strong&gt; Screen, headset, camera-composited world. Learn the scene, not three separate stacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The portable asset was never the platform.&lt;/strong&gt; It's the model: UI as a function of state, reconciled by a renderer against a target.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And that's where I'll leave the React Playbook. Thirty articles, from spinning up a project to rendering a cube in VR, and the honest summary of all of them is smaller than the page count suggests: pick tools that own the messy edges, keep your source of truth in one place, treat the client as untrusted and the boundary as sacred, and describe your UI as a function of state so it can follow you to whatever comes next. The specific libraries will churn — some of the ones I recommended will be replaced, and that's fine. The way of thinking is the part that lasts, and if this series left you with that instead of a list of dependencies, it did its job.&lt;/p&gt;

&lt;p&gt;Thanks for reading this far. If the Playbook changed how you approach even one of these problems — or if you disagree with where I landed on some of them, which is entirely fair — I'd genuinely like to hear about it. That's the conversation that makes writing thirty of these worth it.&lt;/p&gt;

</description>
      <category>react</category>
      <category>3d</category>
      <category>vr</category>
      <category>ar</category>
    </item>
    <item>
      <title>React Native: What 'Learn Once, Write Anywhere' Really Means</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/react-native-what-learn-once-write-anywhere-really-means-4ie</link>
      <guid>https://dev.to/maximusft/react-native-what-learn-once-write-anywhere-really-means-4ie</guid>
      <description>&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%2Fk5mn8dmiqvot8o44iqgv.png" 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%2Fk5mn8dmiqvot8o44iqgv.png" alt="React Native: What 'Learn Once, Write Anywhere' Really Means" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The single most useful thing to get straight about React Native is a slogan, and it's one people consistently misquote. The promise was never "write once, run anywhere." It was "&lt;em&gt;learn&lt;/em&gt; once, write anywhere" — and the difference between those two words is the entire reason web developers either love React Native or feel betrayed by it.&lt;/p&gt;

&lt;p&gt;Because here's what actually transfers to mobile: your React knowledge. Components, props, state, hooks, the mental model of describing UI as a function of data — all of it, intact, on day one. You are not starting over. What does &lt;em&gt;not&lt;/em&gt; transfer is a lot of your code and, more subtly, a lot of your instincts. The &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; you've typed a hundred thousand times doesn't exist. CSS as you know it doesn't exist. The layout habits your fingers have memorized quietly stop working. So you arrive on mobile fluent in the language and unfamiliar with the streets, and whether that feels empowering or frustrating comes down entirely to expecting it.&lt;/p&gt;

&lt;p&gt;This article is about drawing that line honestly — what your React experience genuinely buys you on mobile, and where it stops — so you show up expecting "learn once," not "write once," and don't waste a week being angry at the wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  There Is No DOM. That's the Whole Shift.
&lt;/h2&gt;

&lt;p&gt;Start with the concept that reorganizes everything else: React Native does not render to a browser, because there is no browser. Your components render to &lt;em&gt;actual native platform views&lt;/em&gt; — a real iOS &lt;code&gt;UIView&lt;/code&gt;, a real Android widget. React is the engine describing the UI; the thing it draws is native, not HTML.&lt;/p&gt;

&lt;p&gt;This is why the primitives change. You don't write &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;, because those are HTML and there's no HTML here. You write &lt;code&gt;&amp;lt;View&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt;, which map to genuine native components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;View&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;Pressable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Greeting&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Hi &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tapped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Tap me&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice how &lt;em&gt;familiar&lt;/em&gt; the shape is — it's components, props, a style prop, an event handler. And notice how many small things are different: &lt;code&gt;View&lt;/code&gt; not &lt;code&gt;div&lt;/code&gt;, &lt;code&gt;Text&lt;/code&gt; wrapping every piece of text (you can't just drop a bare string like in HTML), &lt;code&gt;onPress&lt;/code&gt; not &lt;code&gt;onClick&lt;/code&gt;, &lt;code&gt;Pressable&lt;/code&gt; instead of a &lt;code&gt;button&lt;/code&gt;. That's "learn once, write anywhere" in miniature — the &lt;em&gt;shape&lt;/em&gt; of your React knowledge fits perfectly, and every &lt;em&gt;specific&lt;/em&gt; has to be relearned. The engine is the same; the vocabulary is new.&lt;/p&gt;

&lt;h2&gt;
  
  
  Styling: It Looks Like CSS and Isn't
&lt;/h2&gt;

&lt;p&gt;The place this bites hardest, and the one worth dwelling on, is styling — because it looks close enough to CSS that you trust it, then it violates that trust.&lt;/p&gt;

&lt;p&gt;React Native styling &lt;em&gt;resembles&lt;/em&gt; CSS: you write style objects with camelCased properties, and many familiar names work. But it is not CSS, and the differences are exactly the ones your instincts will trip over. There are no cascading stylesheets, no selectors, no inheritance in the way you rely on — styles are explicit, per-component objects. There's no &lt;code&gt;display: block&lt;/code&gt; versus &lt;code&gt;inline&lt;/code&gt;. Most jarring of all: &lt;strong&gt;everything is flexbox, and it's vertical by default.&lt;/strong&gt; On the web, &lt;code&gt;flexDirection&lt;/code&gt; defaults to &lt;code&gt;row&lt;/code&gt;; in React Native it defaults to &lt;code&gt;column&lt;/code&gt;, because a phone screen is tall. Your web layout muscle memory, built on horizontal-by-default flow, is subtly wrong on every screen until you internalize the flip.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StyleSheet&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;styles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// flexDirection defaults to 'column' here — the opposite of the web.&lt;/span&gt;
    &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontWeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;600&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of this is hard. It's just &lt;em&gt;different&lt;/em&gt;, and the danger is precisely that it looks similar enough that you don't respect the difference and spend an afternoon confused why your row is a column. Expect styling to be "CSS-flavored, not CSS," and the friction drops away. Treat it as the CSS you know and it will quietly punish you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Just Use Expo
&lt;/h2&gt;

&lt;p&gt;A practical fork, stated plainly because it saves the most pain: when you start a React Native project, use &lt;a href="https://expo.dev" rel="noopener noreferrer"&gt;Expo&lt;/a&gt;. Bare React Native drops you into native build toolchains — Xcode, Android Studio, native dependency management — which is a heavy, platform-specific world that has nothing to do with React and everything to do with mobile plumbing you didn't sign up to learn on day one.&lt;/p&gt;

&lt;p&gt;Expo is to React Native roughly what &lt;a href="https://ma-x.im/blog/react-playbook-starting-new-project" rel="noopener noreferrer"&gt;a good meta-framework is to React on the web&lt;/a&gt;: it owns the miserable setup and configuration so you can stay in the part you're good at. It handles the build tooling, gives you a clean managed workflow, and bundles access to native device APIs — camera, location, notifications — behind simple JavaScript, so reaching for the camera doesn't mean touching native code. It's the same lesson this whole series keeps landing on: don't hand-assemble the plumbing when a well-made tool already owns it. For the overwhelming majority of apps, Expo is simply the right starting point, and starting bare is a choice you should have a specific reason for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Instinct That Actually Needs Retraining
&lt;/h2&gt;

&lt;p&gt;Beyond the vocabulary, there's a deeper adjustment, and it's the one that separates a web app running on a phone from an app that belongs there. &lt;strong&gt;Mobile is not a small desktop, and users can feel the difference instantly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Touch is not a mouse. There's no hover — a whole category of web interaction just doesn't exist, and designs that lean on it fall flat. Targets have to be finger-sized, not cursor-precise. Gestures — swipe, long-press, pull-to-refresh — are expected vocabulary, not enhancements. Platform conventions differ, and iOS and Android users each have expectations about how navigation and controls should feel that a lowest-common-denominator layout ignores at its peril. And the device itself imposes realities the desktop rarely forces you to think about: constrained screens, variable network, battery, memory. The web developer's instinct to fill horizontal space, to treat the network as reliable, to assume hover and precise pointing — those instincts need retraining, and the app quality lives in that retraining far more than in the syntax.&lt;/p&gt;

&lt;p&gt;This is the honest edge of "learn once, write anywhere": your React &lt;em&gt;transfers&lt;/em&gt;, but your sense of what a good interface &lt;em&gt;is&lt;/em&gt; has to expand from the web's assumptions to the phone's. That's not a knock on React Native — it's the reality of the platform being genuinely native, which is the whole point of using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's "learn once," not "write once."&lt;/strong&gt; Your React knowledge transfers whole; a lot of your code and instincts don't. Expect that and the wall becomes a curriculum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no DOM.&lt;/strong&gt; &lt;code&gt;View&lt;/code&gt; and &lt;code&gt;Text&lt;/code&gt;, not &lt;code&gt;div&lt;/code&gt; and &lt;code&gt;span&lt;/code&gt;; &lt;code&gt;onPress&lt;/code&gt;, not &lt;code&gt;onClick&lt;/code&gt;. You render to real native views.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Styling is CSS-flavored, not CSS.&lt;/strong&gt; No cascade, no selectors, flexbox everywhere, and column-by-default. Respect the difference instead of trusting the resemblance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Expo.&lt;/strong&gt; It owns the native build plumbing so you stay in React. Go bare only with a specific reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrain your interface instincts.&lt;/strong&gt; Touch, gestures, finger targets, platform conventions, device limits — mobile is native, not a narrow desktop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason React Native either delights or frustrates comes down to which promise you showed up believing. Arrive expecting "write once, run everywhere" and every difference feels like a broken promise. Arrive expecting "learn once, write anywhere" and the exact same differences feel like a reasonable tax on real native rendering — you keep the expensive thing, your React fluency, and pay for the platform specifics, which is a genuinely good trade. The knowledge is the asset. The code was always going to be somewhat local to where it runs.&lt;/p&gt;

&lt;p&gt;For the final article, the series leaves flat screens behind completely: &lt;a href="https://ma-x.im/blog/react-playbook-vr-ar" rel="noopener noreferrer"&gt;React in VR and AR&lt;/a&gt; — where the same component model gets pointed at three-dimensional space, and what it means to write &lt;code&gt;&amp;lt;mesh&amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you're a web developer eyeing your first React Native project and bracing for a total restart, don't — it's far more transfer than restart, once you know which parts are which. Tell me what your app needs to do on the phone and I'll tell you where your web instincts will help and where they'll trip you.&lt;/p&gt;

</description>
      <category>react</category>
      <category>reactnative</category>
      <category>mobile</category>
      <category>expo</category>
    </item>
    <item>
      <title>React on the Desktop: When a Window Beats a Tab</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/react-on-the-desktop-when-a-window-beats-a-tab-4fb</link>
      <guid>https://dev.to/maximusft/react-on-the-desktop-when-a-window-beats-a-tab-4fb</guid>
      <description>&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%2Fspqwufcc2cw9ksvh499t.png" 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%2Fspqwufcc2cw9ksvh499t.png" alt="React on the Desktop: When a Window Beats a Tab" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a specific moment where the desktop pitch becomes irresistible. You've built a genuinely good React app, it lives in a browser tab, and someone asks "can we make it a real app?" — with a dock icon, a name, a window of its own. And the answer is yes, easily, with the same React code you already have. Electron and Tauri will both wrap it into a program that installs like any native app. The demo takes an afternoon and feels like magic.&lt;/p&gt;

&lt;p&gt;So the tempting question is "how do I turn my React app into a desktop app," and it's the wrong one to lead with. The right question — the one that actually determines whether this is worth doing and which tool to reach for — is &lt;em&gt;what do I gain by leaving the browser at all?&lt;/em&gt; Because a desktop wrapper that just shows your website in a frameless window is a worse browser tab: heavier, harder to update, and offering the user nothing they didn't already have. The whole justification for going desktop is the stuff a browser tab structurally &lt;em&gt;cannot&lt;/em&gt; do, and if you don't need that stuff, you probably shouldn't be here.&lt;/p&gt;

&lt;p&gt;This article is about that line — what the desktop actually buys you — and the honest tradeoff between the two tools that get you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Browser Tab Genuinely Can't Do
&lt;/h2&gt;

&lt;p&gt;Start with the reason to leave the browser, because it's the whole point. A web page runs in a sandbox, deliberately walled off from the machine it's on — that's a security feature, not a limitation to complain about. But it means there are real capabilities a tab simply doesn't have, and those capabilities are your entire reason for going desktop.&lt;/p&gt;

&lt;p&gt;Deep filesystem access — reading and writing arbitrary files on the user's disk, not the sandboxed trickle a browser allows. Real OS integration — a tray icon, native menus, global keyboard shortcuts that work when your app isn't focused, notifications that feel like the system's own. Talking to native or system-level resources the browser keeps off-limits. Working genuinely offline as a first-class program, not a web page hoping its service worker cached the right things. If your app needs one of these, the desktop isn't a nicety — it's the only place the feature can exist.&lt;/p&gt;

&lt;p&gt;And the contrapositive matters just as much: if your app needs &lt;em&gt;none&lt;/em&gt; of these — if it's fundamentally a web app that someone just wants to "feel" installed — then wrapping it in Electron mostly buys you weight and update headaches. Be honest about which side of that line you're on before you install anything. The desktop is for apps that need to touch the machine, not for apps that want a fancier bookmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Tools, and the Real Tradeoff
&lt;/h2&gt;

&lt;p&gt;Assuming you genuinely need out of the browser, there are two serious choices, and they represent a real architectural fork — not a matter of taste.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Electron&lt;/strong&gt; is the established one — VS Code, Slack, Discord are all Electron. Its model is straightforward: it ships an entire copy of Chromium &lt;em&gt;and&lt;/em&gt; Node.js inside your app. Your React runs in a real Chrome, and you get full Node in the backend process, so everything you know works exactly as expected. The cost is equally straightforward: because every app bundles its own whole browser, a trivial Electron app starts at a hundred-plus megabytes and a chunk of memory before it does anything. You're shipping a browser per app, times every Electron app the user has installed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tauri&lt;/strong&gt; is the modern challenger with a different bet. Instead of bundling Chromium, it uses the operating system's &lt;em&gt;own&lt;/em&gt; built-in webview — the browser engine already on the machine. And instead of Node, its backend is Rust. The payoff is dramatic: Tauri apps are a fraction of the size and memory, often a few megabytes instead of a hundred. The cost is the two edges of that same bet. Using the OS webview means you're rendering on WebKit on macOS, WebView2 on Windows — &lt;em&gt;different engines&lt;/em&gt; — so you inherit cross-browser inconsistency as a desktop concern, which is a genuinely strange thing to have to test for in an installed app. And the native backend is Rust, so any serious system-level logic pulls you into a language your React team may not know.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                Electron Tauri
  Renderer bundled Chromium (same OS webview (WebKit / WebView2,
                everywhere) differs per platform)
  Backend Node.js Rust
  App size 100MB+ (ships a browser) a few MB (uses the OS's)
  You trade size &amp;amp; memory cross-engine testing + Rust

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the choice isn't "which is better," it's which cost you'd rather pay. Electron trades disk and memory for total consistency and a stack your web team already knows. Tauri trades a small footprint for cross-engine testing and a Rust backend. Neither is wrong; they're priced differently, and the right pick depends on whether size or familiarity hurts you more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part That's Actually Different: Two Processes and a Bridge
&lt;/h2&gt;

&lt;p&gt;Whichever you choose, there's one architectural shift that catches web developers off guard, and it's worth understanding because it shapes everything you build. A desktop app is not one environment — it's &lt;em&gt;two&lt;/em&gt;, with a guarded door between them.&lt;/p&gt;

&lt;p&gt;Your React UI runs in the renderer, which is still fundamentally a web context, sandboxed. The powerful stuff — filesystem, OS APIs, native calls — runs in a separate, privileged backend process (Node in Electron, Rust in Tauri). They can't just call each other's functions; they communicate over an explicit bridge, passing messages across. Your React code doesn't read a file directly — it &lt;em&gt;asks&lt;/em&gt; the backend to, and the backend, which actually has the permission, does it and sends the result back.&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;// Renderer (your React UI): it cannot touch the disk itself.&lt;/span&gt;
&lt;span class="c1"&gt;// It asks the privileged backend to do it — Tauri example.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;invoke&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tauri-apps/api/tauri&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="c1"&gt;// Crosses the bridge to the Rust backend, which has real file access.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contents&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;invoke&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;read_note&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;id&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;contents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This should feel familiar, because it's the same pattern the whole series keeps circling: a sandboxed, untrusted frontend asking a privileged, trusted backend to do the dangerous thing — exactly like the &lt;a href="https://ma-x.im/blog/react-playbook-authentication" rel="noopener noreferrer"&gt;client/server split in auth&lt;/a&gt;, just moved onto one machine. The renderer is the browser; the backend process is your server; the bridge is the API between them. Treat that boundary with the same seriousness — validate what crosses it, expose only the specific operations you mean to — and a desktop app stops feeling exotic and starts feeling like a client/server app you happen to ship in one installer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask what you gain, not whether you can.&lt;/strong&gt; If you don't need filesystem, OS integration, or true offline, the desktop is a heavier browser tab. Don't ship one for a fancy bookmark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The desktop is for touching the machine.&lt;/strong&gt; Deep file access, tray/menus/global shortcuts, native resources, first-class offline — that's the whole reason to leave the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electron vs Tauri is a cost choice, not a quality one.&lt;/strong&gt; Electron: big and memory-hungry, but consistent and web-stack-familiar. Tauri: tiny, but cross-engine testing and a Rust backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's two processes with a bridge.&lt;/strong&gt; Your React renderer asks a privileged backend to do the powerful things. Treat that bridge like a client/server boundary, because it is one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing I want you to take from this is that "turn my React app into a desktop app" is the easy, uninteresting half. The wrapper is a solved afternoon. The real work — and the only justification for the extra weight and the two-process complexity — is the native power you reach for once you're out of the sandbox. Go desktop when your app needs the machine. Stay in the tab when it doesn't. And when you do go, remember you didn't escape the client/server split, you just packaged both halves into one window.&lt;/p&gt;

&lt;p&gt;The next article stays off the web but changes machines entirely: &lt;a href="https://ma-x.im/blog/react-playbook-react-native" rel="noopener noreferrer"&gt;React Native and mobile&lt;/a&gt; — where "same code, new platform" gets far more honest about what actually carries over and what doesn't.&lt;/p&gt;

&lt;p&gt;If you're weighing a desktop build and can't tell whether you truly need to leave the browser, that's the exact question worth answering first — and it's usually clearer than it feels. Tell me what native capability you're reaching for and I'll tell you if the desktop is really the only place to get it.&lt;/p&gt;

</description>
      <category>react</category>
      <category>desktop</category>
      <category>electron</category>
      <category>tauri</category>
    </item>
    <item>
      <title>Documenting Components: The Only Docs That Don't Rot</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/documenting-components-the-only-docs-that-dont-rot-499e</link>
      <guid>https://dev.to/maximusft/documenting-components-the-only-docs-that-dont-rot-499e</guid>
      <description>&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%2Fxj2vpx9o8igsg6z4wfg5.png" 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%2Fxj2vpx9o8igsg6z4wfg5.png" alt="Documenting Components: The Only Docs That Don't Rot" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every team I've watched try to document their components starts in the same place: a page in the wiki. Someone writes up the &lt;code&gt;Button&lt;/code&gt; — here are the variants, here's a screenshot, here's a table of props — and for about three weeks it's great. Then someone adds a &lt;code&gt;loading&lt;/code&gt; prop and doesn't update the page. Then the primary color changes and the screenshot is now a lie. A month in, the docs describe a component that no longer exists, and the whole team has quietly learned to not trust them. So they read the source instead, and the docs rot in place, occasionally misleading a new hire.&lt;/p&gt;

&lt;p&gt;This isn't a discipline problem, and treating it as one — "we just need to be better about updating the docs" — is why it keeps happening. People aren't lazy; the docs are structurally doomed. The reason is simple and it's the whole point of this article: &lt;strong&gt;the documentation lives in a different place than the code it describes.&lt;/strong&gt; Two sources of truth, updated by two separate acts of will, and one of them always falls behind. You've seen this exact shape before — it's the same two-sources-of-truth failure as the &lt;a href="https://ma-x.im/blog/react-playbook-websockets" rel="noopener noreferrer"&gt;real-time cache&lt;/a&gt;, just applied to prose instead of state.&lt;/p&gt;

&lt;p&gt;So the question isn't "how do we write better component docs." It's "how do we make docs that &lt;em&gt;can't&lt;/em&gt; drift" — and the answer is to stop writing docs that sit next to the code and start generating them from the code itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Wiki Always Loses
&lt;/h2&gt;

&lt;p&gt;Sit with why the wiki page decays, because the mechanism matters. When you change a component, updating its separate documentation is a &lt;em&gt;second, optional action&lt;/em&gt;. The code change ships value on its own; the doc update is extra work with no immediate payoff, easy to defer, easy to forget, and nothing breaks when you skip it. So under any deadline — which is always — the doc update is the first thing to go. Multiply that across a team and a year, and every hand-maintained doc trends toward wrong.&lt;/p&gt;

&lt;p&gt;The insight that fixes it is to remove the second action entirely. If the documentation is &lt;em&gt;derived from the code&lt;/em&gt; rather than maintained alongside it, then changing the code changes the docs, automatically, with no act of will required. The drift becomes impossible not because everyone got more disciplined, but because there's no longer a gap for drift to live in. This is the same instinct as generating types from a &lt;a href="https://ma-x.im/blog/react-playbook-typescript-patterns" rel="noopener noreferrer"&gt;schema&lt;/a&gt; or &lt;a href="https://ma-x.im/blog/react-playbook-database-turso" rel="noopener noreferrer"&gt;migrations from a model&lt;/a&gt;: one source of truth, everything else derived. Documentation is just another thing that should be derived, not duplicated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Props Table Should Come From the Props
&lt;/h2&gt;

&lt;p&gt;The most concrete version of this: that table of props everyone hand-writes and forgets to update? It should not be hand-written. Your component's props are &lt;em&gt;already&lt;/em&gt; fully described — in its TypeScript types. That's a machine-readable specification of every prop, its type, whether it's required, sitting right there in the code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ButtonProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** Visual style of the button. */&lt;/span&gt;
  &lt;span class="nl"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&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;secondary&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;danger&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** Shows a spinner and disables interaction. */&lt;/span&gt;
  &lt;span class="nl"&gt;loading&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="cm"&gt;/** Called when the button is clicked. */&lt;/span&gt;
  &lt;span class="nl"&gt;onClick&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ButtonProps&lt;/span&gt;&lt;span class="p"&gt;)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything a props table would contain is in that interface — the names, the exact allowed values of &lt;code&gt;variant&lt;/code&gt;, which props are optional, and even the descriptions in the doc comments. Tooling like &lt;a href="https://storybook.js.org" rel="noopener noreferrer"&gt;Storybook&lt;/a&gt; reads those types directly and generates the props table for you. Add a prop, the table gains a row. Change &lt;code&gt;variant&lt;/code&gt;'s options, the table updates. Delete a prop, its row disappears. You didn't maintain anything — the documentation is a &lt;em&gt;view&lt;/em&gt; of the types, and the types can't be wrong because they're the code that actually runs. The single most rot-prone piece of component docs becomes the single most reliable one, purely by deriving it instead of writing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Living Examples Beat Screenshots
&lt;/h2&gt;

&lt;p&gt;The other half of component docs is showing the thing in use, and here the screenshot is the villain. A screenshot is a photograph of the component at one moment in the past — it stops being accurate the instant anything changes, and it can't show a single interactive state. Hover, focus, loading, disabled, the error variant — a static image shows none of it and silently goes stale on all of it.&lt;/p&gt;

&lt;p&gt;The fix is the same move again: don't take a picture of the component, &lt;em&gt;render the real component&lt;/em&gt;. A story in Storybook is a live instance of the actual component, mounted with real props, that you can interact with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;StoryObj&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@storybook/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Meta&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;Button&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;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;meta&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;Primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StoryObj&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;Button&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;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="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;Loading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StoryObj&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;Button&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;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="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;Those aren't images of a button — they're the button, running, with the exact props your app would pass. If the button changes, the stories change with it, because they &lt;em&gt;are&lt;/em&gt; the component, not a snapshot of it. You can hover the real hover state, watch the real spinner, tab to the real focus ring. A screenshot documents what the component looked like once; a story documents what the component &lt;em&gt;is&lt;/em&gt; right now, and stays honest by construction.&lt;/p&gt;

&lt;p&gt;And there's a compounding benefit that ties back to the &lt;a href="https://ma-x.im/blog/react-playbook-design-prototyping" rel="noopener noreferrer"&gt;design conversation&lt;/a&gt;: a living component catalog is where designers and engineers actually agree on what exists. When the real &lt;code&gt;Button&lt;/code&gt;, in all its states, is browsable in one place, "does this already exist?" has an answer you can see and click — which is the difference between reusing the component and rebuilding it slightly differently for the fifth time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate docs always rot.&lt;/strong&gt; It's not a discipline problem — it's two sources of truth. Stop maintaining docs next to the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive, don't duplicate.&lt;/strong&gt; Generate documentation from the code so changing the code changes the docs, automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The props table comes from the types.&lt;/strong&gt; Your TypeScript interface already is the spec; let tooling render it. Never hand-write a props table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render real components, not screenshots.&lt;/strong&gt; A live story stays accurate and shows interactive states; an image is stale the moment you save it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A living catalog drives reuse.&lt;/strong&gt; When every component is browsable in all its states, "does this exist?" becomes answerable — and things get reused instead of rebuilt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason component docs have such a bad reputation is that almost everyone builds them to fail, then blames themselves when they do. A wiki page and a screenshot are drift waiting to happen, and no amount of resolve fixes a structural flaw. The shift that makes documentation actually last is to stop treating it as a thing you &lt;em&gt;write&lt;/em&gt; and start treating it as a thing you &lt;em&gt;derive&lt;/em&gt; — from the types, from the real components, from the code that's already the truth. Do that, and your docs stop being a chore you fall behind on and become a side effect of building the components at all. That's the only kind of documentation I've seen survive contact with a deadline.&lt;/p&gt;

&lt;p&gt;This is where the "build a real app" spine of the series ends — from starting a project all the way through documenting what you built. The remaining articles step outside the browser entirely: next up, &lt;a href="https://ma-x.im/blog/react-playbook-desktop" rel="noopener noreferrer"&gt;React on the desktop&lt;/a&gt; — when Electron or Tauri actually earns its place, and what changes when your React app is a window instead of a tab.&lt;/p&gt;

&lt;p&gt;If your team has component docs that nobody trusts, it's almost certainly the separate-source-of-truth problem, and it's fixable without a documentation crusade. Tell me how your docs are set up and I'll tell you which piece to derive instead of write.&lt;/p&gt;

</description>
      <category>react</category>
      <category>documentation</category>
      <category>storybook</category>
      <category>reactplaybook</category>
    </item>
    <item>
      <title>Design Prototyping for React: Where the Architecture Actually Starts</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Fri, 29 May 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/design-prototyping-for-react-where-the-architecture-actually-starts-42oj</link>
      <guid>https://dev.to/maximusft/design-prototyping-for-react-where-the-architecture-actually-starts-42oj</guid>
      <description>&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%2F8oqb3vowp80yvh23fsdq.png" 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%2F8oqb3vowp80yvh23fsdq.png" alt="Design Prototyping for React: Where the Architecture Actually Starts" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The way most engineers relate to a design file is as a target to hit. The designer hands over a Figma frame, and the job — as everyone tacitly agrees — is to make the screen look like the picture. Pixel for pixel, color for color. Reproduce it, get it approved, move on.&lt;/p&gt;

&lt;p&gt;I want to push on that framing, because I think it's the root of a lot of avoidable pain. A design isn't a picture you copy. It's the first draft of your component architecture, whether anyone treats it that way or not. The structure of that screen — what repeats, what nests, what varies, where the boundaries fall — &lt;em&gt;is&lt;/em&gt; the structure of the code you're about to write. Which means the most consequential architectural decisions in a feature are often made in a design tool, by someone thinking about spacing and hierarchy, long before an engineer opens their editor. And when the design's structure and the code's structure disagree, you get the codebases everyone recognizes: technically matching the mockup, quietly miserable to maintain.&lt;/p&gt;

&lt;p&gt;So this article is about reading a design the way it deserves to be read — as architecture — and why that shift changes both what you build and how you work with the people who design it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Design Has Structure, Not Just Appearance
&lt;/h2&gt;

&lt;p&gt;Look at any real screen and there are two different things present at once. There's the &lt;em&gt;appearance&lt;/em&gt; — the colors, the fonts, the shadows, the exact pixels. And there's the &lt;em&gt;structure&lt;/em&gt; — this card repeats six times, that button is the same button as the one on the other screen, this panel contains a header and a list, these three stats are the same shape with different data.&lt;/p&gt;

&lt;p&gt;Reproducing appearance is the shallow job. Reading structure is the real one, because structure is what maps to components. When you see six cards, the architectural fact isn't "six cards" — it's "one &lt;code&gt;Card&lt;/code&gt; component, rendered six times." When you see a button here that's clearly the same button as three other screens, the fact is "this is one shared &lt;code&gt;Button&lt;/code&gt;, not a thing I restyle locally each time." The design is quietly telling you what your components are, what your reusable primitives are, and how they compose. A developer who only sees the picture reproduces the pixels and invents an ad-hoc structure underneath. A developer who reads the structure lets the design hand them their &lt;a href="https://ma-x.im/blog/react-playbook-code-structure" rel="noopener noreferrer"&gt;component boundaries&lt;/a&gt; for free.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A mockup shows you what the screen looks like. Read carefully, it also shows you what your components are, which ones repeat, and where they compose. The second reading is the one that keeps your codebase sane.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Same Component, Different States — the Thing Static Mockups Hide
&lt;/h2&gt;

&lt;p&gt;Here's where the "design is a picture" framing does the most damage, and it's the single most valuable thing to internalize about prototyping for React.&lt;/p&gt;

&lt;p&gt;A static mockup shows you one moment. But a React component is never one moment — it's every state that data can put it in. The screen you were handed shows a list with eight items. Your component also has to handle: zero items (the empty state), one item, hundreds (does it scroll, paginate, virtualize?), the loading moment before any data arrives, and the error case when the fetch fails. The mockup showed you one of five or six realities, and if you only build the one you were shown, you'll ship a component that looks perfect in the demo and breaks the first time real data is empty or slow.&lt;/p&gt;

&lt;p&gt;This is why the most useful thing you can do with a design &lt;em&gt;isn't&lt;/em&gt; to start coding it — it's to interrogate it. Before writing the component, I go looking for the states the mockup didn't show. What does this look like with no data? While it's loading? When it fails? When the text is three times longer than the placeholder? When there are two hundred rows instead of eight? Half the time the honest answer is "the designer didn't think about that yet," and surfacing it &lt;em&gt;before&lt;/em&gt; you build is enormously cheaper than discovering it in QA. The static frame is the happy path; your component is responsible for all the paths, and prototyping is where you find the missing ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  This Is a Conversation, Not a Handoff
&lt;/h2&gt;

&lt;p&gt;That interrogation is exactly why I don't think of design-to-code as a handoff. A handoff is one-directional: design finishes, throws the result over a wall, engineering catches it. But the questions that matter — what are the empty and error states, is this really the same component as that one, does this pattern already exist in our system — can only be answered &lt;em&gt;together&lt;/em&gt;, and answering them early is where the leverage is.&lt;/p&gt;

&lt;p&gt;When engineering engages with design &lt;em&gt;during&lt;/em&gt; prototyping instead of after, a few things change. Reusable pieces get spotted before they're built five slightly-different times. Missing states get designed instead of improvised by whichever developer hits them first. And the design starts to align with the components that already exist — which connects straight back to the &lt;a href="https://ma-x.im/blog/building-design-systems-that-scale" rel="noopener noreferrer"&gt;design systems&lt;/a&gt; idea: a prototype built from the components you already have is a prototype that ships in a fraction of the time, because the answer to "how do I build this" is "you mostly already did." The handoff model forecloses all of that by making the conversation happen too late, after the structural decisions have already hardened on both sides.&lt;/p&gt;

&lt;p&gt;I'm not arguing engineers should design. I'm arguing that the boundary between "design work" and "architecture work" is far blurrier than the org chart suggests, and pretending it's a clean line — a wall with a handoff over it — is how you get designs that fight the code and code that fights the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read a design for structure, not just appearance.&lt;/strong&gt; What repeats, what nests, what varies — that's your component tree, handed to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A static mockup is one state.&lt;/strong&gt; Before coding, hunt for the ones it hides: empty, loading, error, overflow, huge data. Build all the realities, not the one you were shown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interrogate before you implement.&lt;/strong&gt; The cheapest place to discover a missing state is a design review, not QA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat it as a conversation, not a handoff.&lt;/strong&gt; Engaging during prototyping surfaces reuse and missing states while they're still cheap to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prototype from your existing components.&lt;/strong&gt; A design aligned to the system you already have ships far faster than one drawn from scratch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reframe that changed how I work is small but load-bearing: the design tool is where architecture starts, not where it waits. The person arranging cards and spacing is making decisions about repetition, hierarchy, and composition — the exact decisions I'll be encoding in components an hour later. When I treat the mockup as a picture to copy, I throw all that structural information away and rebuild it, worse, in code. When I treat it as the first draft of the architecture, the design does half the hard thinking for me — and the half it left out becomes a conversation instead of a bug.&lt;/p&gt;

&lt;p&gt;Next, the flip side of building components well: &lt;a href="https://ma-x.im/blog/react-playbook-component-documentation" rel="noopener noreferrer"&gt;documenting them&lt;/a&gt; — so the reusable pieces you and your designers just agreed on actually get reused instead of quietly rebuilt for the fifth time.&lt;/p&gt;

&lt;p&gt;If your team's design-to-code process feels like reproducing pictures and constantly rediscovering missing states in QA, that's the handoff model doing its usual damage. Tell me where it hurts — the reuse, the missing states, the drift — and I'll tell you where the conversation needs to move earlier.&lt;/p&gt;

</description>
      <category>react</category>
      <category>design</category>
      <category>architecture</category>
      <category>reactplaybook</category>
    </item>
    <item>
      <title>Sending Email from a React App: A Backend Problem in Disguise</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/sending-email-from-a-react-app-a-backend-problem-in-disguise-57eo</link>
      <guid>https://dev.to/maximusft/sending-email-from-a-react-app-a-backend-problem-in-disguise-57eo</guid>
      <description>&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%2Fbgax8ydphx0b7at9mhpb.png" 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%2Fbgax8ydphx0b7at9mhpb.png" alt="Sending Email from a React App: A Backend Problem in Disguise" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"When someone signs up, just send them a welcome email." It's the kind of request that gets tossed into a standup like it's nothing, and the person saying it genuinely believes it's a five-minute task. Send an email. How hard could it be?&lt;/p&gt;

&lt;p&gt;Here's the thing that makes email sneaky: the part you'd expect to be hard is trivial, and the part you'd expect to be trivial is where the whole thing lives. Triggering an email from your app is a single function call. &lt;em&gt;Actually getting that email into someone's inbox&lt;/em&gt; — not their spam folder, not bounced, not silently dropped — is a genuinely deep backend problem involving deliverability, sender reputation, authentication protocols, and a rendering environment stuck two decades in the past. And almost none of it is a React problem, which is exactly why it catches frontend developers off guard.&lt;/p&gt;

&lt;p&gt;So this article is a map of where the real work is. Because the most useful thing I can tell you about email in a React app is which parts to not do yourself, and why the boundary sits where it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Line: The Client Never Sends Email
&lt;/h2&gt;

&lt;p&gt;Start with the hard boundary, because it's the one people are tempted to cross. &lt;strong&gt;Your React app does not send email. Ever.&lt;/strong&gt; It triggers a request to your backend, and your backend sends the email.&lt;/p&gt;

&lt;p&gt;This isn't a style preference, it's a security wall. Sending email requires credentials — API keys for your email service — and anything in your React bundle is public. Ship that key to the client and you've handed the entire internet the ability to send email &lt;em&gt;as you&lt;/em&gt;, which means spammers now have a free megaphone wearing your domain's reputation. Within days your domain is blacklisted and even your legitimate mail stops arriving. So the flow is always the same shape as &lt;a href="https://ma-x.im/blog/react-playbook-payments" rel="noopener noreferrer"&gt;payments&lt;/a&gt; and &lt;a href="https://ma-x.im/blog/react-playbook-authentication" rel="noopener noreferrer"&gt;auth&lt;/a&gt;: the client asks, the server acts, and the credential never leaves the server.&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;// The client's entire involvement: ask the backend to do it.&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;requestWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/emails/welcome&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole frontend story. There is no &lt;code&gt;sendEmail&lt;/code&gt; in your components, no email API key in your environment variables with a public prefix, no SMTP in the browser. The moment email touches the client, you've done it wrong. Everything interesting from here happens on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Don't Talk to SMTP Yourself
&lt;/h2&gt;

&lt;p&gt;On the backend, the naive mental model is that you connect to a mail server over SMTP and send. You &lt;em&gt;can&lt;/em&gt; do that. You shouldn't, and understanding why is the core of this whole topic.&lt;/p&gt;

&lt;p&gt;Getting an email delivered is not about transmitting it — it's about being &lt;em&gt;trusted&lt;/em&gt; enough that the receiving server puts it in the inbox instead of the spam folder or the trash. That trust is a system: your domain needs authentication records (SPF, DKIM, DMARC) that prove you're allowed to send as your domain; your sending IP needs a &lt;em&gt;reputation&lt;/em&gt; built over time; you need to handle bounces and complaints so you stop mailing dead addresses; and you need to not trip the spam filters that treat unknown senders with suspicion. Running your own mail server means owning all of that — reputation, blacklist monitoring, authentication, the works — and it is a full-time infrastructure job that has nothing to do with your product.&lt;/p&gt;

&lt;p&gt;This is why transactional email services — &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt;, Postmark, SendGrid — exist, and why using one isn't laziness but the correct architecture. They've spent years building sender reputation, they handle the authentication protocols, they manage bounces and deliverability, and they give you a clean API. Your backend calls that API; the hard, invisible work of &lt;em&gt;actually getting delivered&lt;/em&gt; is theirs.&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;// Your server. The service owns deliverability; you own intent.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Resend&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resend&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;Resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// server-only secret&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/emails/welcome&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;user&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;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello@yourdomain.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Welcome aboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;react&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;WelcomeEmail&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;, /&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;more&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="nx"&gt;below&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same lesson as every article in this series: the raw capability (SMTP) is a footgun, and the value is a service that owns the messy, reputation-dependent edges. You are buying deliverability, not a send button.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Genuinely Weird Part: Email Rendering Is Stuck in 2005
&lt;/h2&gt;

&lt;p&gt;Here's the one place your React skills partly transfer, and it comes with a nasty surprise. You'd assume you can build an email template with normal components and modern CSS. You cannot, and this trips up every frontend developer exactly once.&lt;/p&gt;

&lt;p&gt;Email clients — Outlook especially, but not only — render HTML with engines that are decades behind browsers. No flexbox, no grid, no modern CSS, inconsistent support for things you consider baseline. The reliable way to lay out an email is &lt;em&gt;tables&lt;/em&gt;, the way the web worked in 2005, with inlined styles because &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; blocks get stripped. Hand-writing that is miserable and easy to get wrong across the dozens of clients your users actually use.&lt;/p&gt;

&lt;p&gt;The modern rescue is that you &lt;em&gt;can&lt;/em&gt; write email templates as React components — using a library like &lt;a href="https://react.email" rel="noopener noreferrer"&gt;React Email&lt;/a&gt; — and it compiles them down to the table-based, inline-styled HTML that survives Outlook. You get to think in components; the library deals with the 2005 rendering reality underneath.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Button&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@react-email/components&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Hi &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;, glad you're here.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://yourdomain.com/start"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Get started&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks like a normal component, but &lt;code&gt;&amp;lt;Button&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt; aren't rendering &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; — they emit the gnarly, table-wrapped, inline-styled markup that renders consistently from Gmail to a decade-old Outlook. It's the same pattern once more: a library absorbing an ugly reality so you can work in a sane abstraction. You write React; it ships something that would make you cry if you had to write it by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The client never sends email.&lt;/strong&gt; It asks the backend. The API key is a server-only secret — leaking it burns your domain's reputation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't run your own mail server.&lt;/strong&gt; Deliverability is a reputation-and-authentication system, not a transmission problem. Use a transactional email service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're buying deliverability, not a send button.&lt;/strong&gt; SPF/DKIM/DMARC, bounce handling, and IP reputation are the actual product these services sell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email rendering is stuck in the past.&lt;/strong&gt; Write templates as React components via React Email; let it compile down to the table-based HTML that survives Outlook.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason "just send an email" turns into a week is that the sentence hides the entire iceberg. The tip — trigger a send — is the trivial thing everyone pictures. The mass underneath is deliverability, authentication, reputation, and a rendering environment that predates most of the developers working on it. The good news is that the whole iceberg is a solved, buyable problem: a transactional service for delivery, React Email for templates, and a hard rule that none of it touches the client. Get the boundary right and email goes back to being the five-minute task everyone assumed it was — because someone else already did the hard 95%.&lt;/p&gt;

&lt;p&gt;Next, the series shifts from wiring things up to thinking about them differently: &lt;a href="https://ma-x.im/blog/react-playbook-design-prototyping" rel="noopener noreferrer"&gt;design prototyping for React&lt;/a&gt; — where design work stops being visual and starts being architectural, and why that handoff matters more than people admit.&lt;/p&gt;

&lt;p&gt;If you've got emails landing in spam, or a template that looks perfect everywhere except Outlook, those are the two classic email potholes — deliverability and rendering — and both have known fixes. Tell me which one's biting you and I'll point you at the layer to check.&lt;/p&gt;

</description>
      <category>react</category>
      <category>email</category>
      <category>backend</category>
      <category>reactplaybook</category>
    </item>
    <item>
      <title>File Upload in React: The Input Is Easy, the File Is Not</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/file-upload-in-react-the-input-is-easy-the-file-is-not-1p6l</link>
      <guid>https://dev.to/maximusft/file-upload-in-react-the-input-is-easy-the-file-is-not-1p6l</guid>
      <description>&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%2F9je98pm82d1fp77vk9uk.png" 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%2F9je98pm82d1fp77vk9uk.png" alt="File Upload in React: The Input Is Easy, the File Is Not" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Adding a file upload feels like a solved problem. You drop an &lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt; on the page, grab the file in an &lt;code&gt;onChange&lt;/code&gt;, POST it to an endpoint. Ten minutes of work, and on your machine — with a 40KB avatar and localhost — it works flawlessly. You move on.&lt;/p&gt;

&lt;p&gt;Then it meets reality. Someone uploads a two-gigabyte video from hotel wifi. Someone's connection drops at 90%. Someone picks a 500MB file and your server, which happily buffered the 40KB avatar into memory, tries to do the same with half a gigabyte and falls over. The file input was never the hard part. The hard part is that a file is a &lt;em&gt;large, unreliable stream of bytes&lt;/em&gt; that has to travel from an untrusted browser, across a flaky network, to somewhere it can actually live — and every one of those words is a problem.&lt;/p&gt;

&lt;p&gt;So this article isn't about the input element. It's about the thing underneath: moving real files, at real sizes, over real networks, without melting your server or lying to your user about what's happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Decision: Your Server Shouldn't Touch the File
&lt;/h2&gt;

&lt;p&gt;Here's the instinct that causes most upload pain: the file goes browser → your server → storage. Your React app POSTs to your API, your API receives the bytes and forwards them to S3 or wherever. It's the obvious shape, and it's the wrong one for anything but tiny files.&lt;/p&gt;

&lt;p&gt;Why it's wrong: your server now has to &lt;em&gt;hold&lt;/em&gt; the file — in memory or on disk — while it passes through. A handful of users uploading large files at once and your server is doing nothing but shuttling bytes, burning memory and connection time on work that isn't yours. You've turned your application server into a file relay, and file relays are exactly what dedicated storage services already are, better than you.&lt;/p&gt;

&lt;p&gt;The pattern that fixes it is &lt;strong&gt;presigned uploads&lt;/strong&gt; , and it's worth understanding because it reshapes the whole flow. The file goes browser → storage &lt;em&gt;directly&lt;/em&gt;, and your server's only job is to hand out permission. The client asks your API "I want to upload this file," your API asks the storage service for a short-lived, single-purpose URL, and hands that URL back. The browser then uploads straight to storage. Your bytes never touch your server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Your server: it never sees the file. It only grants permission.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/uploads/sign&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Ask storage for a short-lived URL scoped to this one upload.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uploadUrl&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;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createPresignedUrl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`uploads/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// seconds — the URL is useless after that&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;uploadUrl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;


&lt;span class="c1"&gt;// The browser: uploads the bytes directly to storage, not to you.&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;uploadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;uploadUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/uploads/sign&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uploadUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// straight to storage&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at how the responsibilities split. Your server does a tiny, fast, cheap thing — validate the request, mint a scoped URL, respond. The multi-gigabyte transfer happens between the browser and a service built for exactly that. This is the same &lt;a href="https://ma-x.im/blog/react-playbook-authentication" rel="noopener noreferrer"&gt;server-is-the-authority, client-does-the-work&lt;/a&gt; split from the auth and payments articles: your backend controls &lt;em&gt;permission&lt;/em&gt;, but it doesn't do the heavy lifting it has no business doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Reality: The Upload Is a Process, Not a Request
&lt;/h2&gt;

&lt;p&gt;Once the file is going straight to storage, the next thing to accept is that a large upload is not an event that happens — it's a process that &lt;em&gt;unfolds over time&lt;/em&gt;, and your UI has to treat it that way.&lt;/p&gt;

&lt;p&gt;A 40KB avatar uploads instantly, so you can pretend it's a normal request. A 500MB file takes a minute or more, and during that minute the user is staring at your interface wondering if it's working. If you show them a frozen spinner, they'll assume it hung and refresh — killing the upload. So progress isn't a nice-to-have here; it's the difference between a feature that works and one users abort halfway. You have to report how far along the bytes are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uploadWithProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;xhr&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;XMLHttpRequest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;xhr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;progress&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lengthComputable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;xhr&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;xhr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;xhr.upload.progress&lt;/code&gt; event — one of the few times raw &lt;code&gt;XMLHttpRequest&lt;/code&gt; still earns its place over &lt;code&gt;fetch&lt;/code&gt;, which can't report upload progress — is what lets you draw a real progress bar. And a real progress bar changes user behavior: people wait for a bar that moves, and they abandon a spinner that doesn't. The technical work of measuring progress is small; the UX consequence of showing it is large.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Third Reality: Big Files Need to Survive a Dropped Connection
&lt;/h2&gt;

&lt;p&gt;Now the hardest layer, and the one that separates a toy from a product: what happens at 90% when the wifi blinks?&lt;/p&gt;

&lt;p&gt;With a single PUT of the whole file, the answer is brutal — the upload fails and starts over from zero. For a 40KB file, who cares. For a 2GB file on a shaky connection, "start over from zero" may mean it &lt;em&gt;never&lt;/em&gt; completes, because the odds of an uninterrupted multi-gigabyte transfer on bad wifi are genuinely low. The single-request model has a size ceiling baked in, and above it, uploads just don't finish.&lt;/p&gt;

&lt;p&gt;The answer is &lt;strong&gt;chunked (multipart) uploads&lt;/strong&gt;: split the file into pieces, upload each piece independently, and reassemble them in storage. Now a dropped connection costs you one chunk, not the whole file — you retry that chunk and continue. Uploads become resumable, and the size ceiling effectively disappears.&lt;/p&gt;

&lt;p&gt;I want to be honest about the tradeoff, because this is where the "it's just an input" fantasy fully dies: chunking is genuinely more complex. You're tracking which chunks succeeded, retrying the ones that didn't, handling out-of-order completion, and telling storage to stitch them back together. This is real coordination logic, and it's exactly why you don't hand-roll it. Storage services expose multipart upload APIs, and libraries like &lt;a href="https://uppy.io" rel="noopener noreferrer"&gt;Uppy&lt;/a&gt; wrap the whole thing — chunking, retries, progress, resumability — behind a component. The same lesson as every article in this series: the raw capability is a footgun, and the value is a library that owns the messy edges so you spend your attention on your product, not on retry bookkeeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't Forget: The File Is Untrusted
&lt;/h2&gt;

&lt;p&gt;One security note that's too important to skip, because uploads are a classic attack surface. A file arriving from a browser is &lt;em&gt;user input&lt;/em&gt;, and the same rule from the &lt;a href="https://ma-x.im/blog/react-playbook-payments" rel="noopener noreferrer"&gt;payments&lt;/a&gt; and &lt;a href="https://ma-x.im/blog/react-playbook-authentication" rel="noopener noreferrer"&gt;auth&lt;/a&gt; articles applies: never trust it.&lt;/p&gt;

&lt;p&gt;The client can claim a file is a 2MB image and actually send a 2GB executable. It can lie about the content type. So validation belongs on the boundary you control — size limits, allowed types, and ideally scanning — enforced when you mint the presigned URL and verified again on the storage side, never solely in the browser where a hostile user can bypass it. Client-side checks are for UX (tell the user "that's too big" before they wait); server-side checks are for safety. You need both, and you must never mistake the first for the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your server hands out permission, not bytes.&lt;/strong&gt; Use presigned URLs so the file goes browser → storage directly. Don't turn your API into a file relay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the upload as a process.&lt;/strong&gt; Show real progress from &lt;code&gt;xhr.upload&lt;/code&gt;. A moving bar is why users wait instead of refreshing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunk large files.&lt;/strong&gt; Multipart uploads make transfers resumable and remove the size ceiling. Don't hand-roll it — use the storage API via a library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The file is untrusted input.&lt;/strong&gt; Validate size and type on the server boundary, not just in the browser. Client checks are UX; server checks are security.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason file upload is a rite of passage is that the easy version and the real version look identical right up until the file gets big or the network gets bad. The input, the POST, the success toast — all of that is the 10% you can write in your sleep. The 90% is respecting that you're moving a large, untrusted, interruptible thing across a connection you don't control, to a service that should be doing the heavy carrying instead of your server. Get that shape right — permission from your backend, bytes to real storage, progress and resumability for the user — and uploads stop being the feature that falls over in production.&lt;/p&gt;

&lt;p&gt;Next, another piece of infrastructure that looks trivial and isn't: &lt;a href="https://ma-x.im/blog/react-playbook-mails" rel="noopener noreferrer"&gt;sending email from a React app&lt;/a&gt; — why "just send an email" is a backend problem wearing a frontend costume, and where the real work hides.&lt;/p&gt;

&lt;p&gt;If you've got an upload that works for small files and dies on big ones, that's the single-request ceiling, and it's the most common upload bug there is. Tell me where it breaks — the server memory, the timeout, the 90% drop — and I'll tell you which layer is missing.&lt;/p&gt;

</description>
      <category>react</category>
      <category>fileupload</category>
      <category>storage</category>
      <category>reactplaybook</category>
    </item>
    <item>
      <title>Time in React: The Bugs That Only Show Up at 2 AM</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/time-in-react-the-bugs-that-only-show-up-at-2-am-j6</link>
      <guid>https://dev.to/maximusft/time-in-react-the-bugs-that-only-show-up-at-2-am-j6</guid>
      <description>&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%2Fi5s8v7gzeowh9eonrnwg.png" 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%2Fi5s8v7gzeowh9eonrnwg.png" alt="Time in React: The Bugs That Only Show Up at 2 AM" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A date is just a value. It sits there in your data looking as harmless as a string or a number, and for a while it behaves. Then one day a user in another timezone reports that their meeting shows an hour off, or a "created today" badge appears on something made yesterday, or a date field creeps backward by one day every single time someone saves the form. Suddenly the most boring data type in your app is the source of the weirdest bug in your tracker.&lt;/p&gt;

&lt;p&gt;Time is deceptive because it looks simple and is not. The complexity is real and mostly invisible on the machine you develop on, because your machine has one timezone, and so the entire category of problems — timezones, daylight saving, what "midnight" even means — stays hidden until someone in a different part of the world hits it. And they always hit it, usually in production, usually at an hour that makes the bug's name feel personal.&lt;/p&gt;

&lt;p&gt;So this article is about the small number of ideas that keep time from breaking a React app. Not a date-library tour — a way of thinking about time that makes the whole class of bugs stop happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Rule That Prevents Most of It
&lt;/h2&gt;

&lt;p&gt;If you take nothing else from this: &lt;strong&gt;store and transmit time in UTC. Convert to the user's local timezone only for display.&lt;/strong&gt; That's it. That single discipline, applied without exception, eliminates the majority of time bugs before they can form.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A timestamp is a fact — the exact instant something happened. A timezone is a lens for &lt;em&gt;viewing&lt;/em&gt; that fact. Store the fact once, in UTC. Choose the lens at the last possible moment, at render.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's why this works. An instant in time is absolute — the moment a user clicked "submit" is the same moment everywhere on Earth. What differs is how you &lt;em&gt;describe&lt;/em&gt; it: 3 PM in London, 10 AM in New York, same instant. The bug factory is storing or comparing times in local terms, because then "the same moment" has many different labels and they drift against each other. Store the absolute fact in UTC, keep it in UTC through your database and your API, and only bend it into a human's local timezone when you're about to show it to that specific human. Timezone becomes a &lt;em&gt;display&lt;/em&gt; concern, which is the only place it belongs.&lt;/p&gt;

&lt;p&gt;The mirror-image mistake — the one that produces the "date shifts by a day every save" bug — is round-tripping a date through local time on the way in &lt;em&gt;and&lt;/em&gt; out, so each save nudges it by the offset. Keep the storage UTC and the conversion display-only, and that whole genre of bug can't happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;new Date()&lt;/code&gt; Keeps Betraying You
&lt;/h2&gt;

&lt;p&gt;The native &lt;code&gt;Date&lt;/code&gt; object is where good intentions go to die, and it's worth being specific about why, because the failure is silent.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Date&lt;/code&gt; is implicitly local. The moment you parse or construct one, it quietly adopts the runtime's timezone — which is your laptop in development and the user's machine (or worse, a server in yet another zone) in production. So code that looks correct on your screen produces different results for different users, and you won't see it, because your machine only ever shows you your own timezone's answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks innocent. Behaves differently depending on where it runs.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-07-21&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// midnight UTC... interpreted as LOCAL&lt;/span&gt;
&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 21 in London. 20 in New York. Same code, different day.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;getDate()&lt;/code&gt; returning 20 for a New York user is the entire "off by one day" bug in miniature — the string is midnight UTC, but &lt;code&gt;Date&lt;/code&gt; shows it through local eyes, and New York's local eyes are still on the 20th. Nothing threw an error. The value is just wrong, for some people, some of the time, which is the worst kind of wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reach for a Real Library — and Watch Temporal
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;Date&lt;/code&gt; is a minefield, the long-standing answer is a library that makes timezone handling explicit instead of implicit. &lt;a href="https://date-fns.org" rel="noopener noreferrer"&gt;date-fns&lt;/a&gt; is my usual pick — modular, tree-shakeable, and its companion &lt;code&gt;date-fns-tz&lt;/code&gt; makes conversions something you &lt;em&gt;state&lt;/em&gt; rather than something that happens to you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;formatInTimeZone&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;date-fns-tz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The instant is stored in UTC. Display it in the user's zone, explicitly.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;utcInstant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-07-21T23:30:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;formatInTimeZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;utcInstant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;America/New_York&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;yyyy-MM-dd HH:mm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "2026-07-21 19:30"&lt;/span&gt;
&lt;span class="nf"&gt;formatInTimeZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;utcInstant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Asia/Tokyo&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;yyyy-MM-dd HH:mm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "2026-07-22 08:30"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the same UTC instant renders as the 21st in New York and the 22nd in Tokyo — correctly, because the conversion is &lt;em&gt;explicit&lt;/em&gt; and timezone-aware, not left to whatever &lt;code&gt;Date&lt;/code&gt; guesses. You're stating the lens instead of inheriting it.&lt;/p&gt;

&lt;p&gt;The genuinely good news is that the language itself is fixing this. &lt;strong&gt;Temporal&lt;/strong&gt; — the new built-in date/time API — is designed specifically to end the &lt;code&gt;Date&lt;/code&gt; era: immutable objects, explicit timezone handling, no more implicit-local footguns. It's landing in engines now, and it's the direction everything moves. If you're starting fresh, it's worth watching closely and reaching for as it stabilizes; it makes the "be explicit about timezones" discipline the &lt;em&gt;default&lt;/em&gt; instead of something you bolt on with a library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Traps That Remain
&lt;/h2&gt;

&lt;p&gt;Even with the UTC rule and a real library, a few sharp edges stay sharp, and they're worth naming because each has bitten real apps.&lt;/p&gt;

&lt;p&gt;Daylight saving time means some days don't have 24 hours and some local times don't exist at all — the clock jumps forward and 2:30 AM simply never happened on that date. Any code doing time math by adding &lt;code&gt;24 * 60 * 60 * 1000&lt;/code&gt; milliseconds and calling it "a day" is wrong twice a year; a real library that adds &lt;em&gt;calendar&lt;/em&gt; days handles it, hand-rolled arithmetic does not.&lt;/p&gt;

&lt;p&gt;"Midnight" and "today" are not absolute either — they're timezone-bound. "Show me everything from today" means a different range of instants for a user in Los Angeles than one in Berlin, and if your backend computes "today" in the server's timezone, half your users see the wrong day's data. "Today" is always &lt;em&gt;someone's&lt;/em&gt; today, and you have to decide whose.&lt;/p&gt;

&lt;p&gt;And serialization is where discipline leaks: the safe way to put a date on the wire is ISO 8601 in UTC (that trailing &lt;code&gt;Z&lt;/code&gt;), because it's unambiguous. A date formatted for humans — or worse, a bare &lt;code&gt;new Date().toString()&lt;/code&gt; — is a guessing game for whatever parses it next. Keep the API boundary UTC and ISO, and convert to human-readable strictly at the edges, at display.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store and send UTC. Convert only for display.&lt;/strong&gt; The single rule that prevents most time bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distrust &lt;code&gt;new Date()&lt;/code&gt;.&lt;/strong&gt; It's implicitly local, so it's implicitly wrong for someone. Don't do timezone logic with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a real library&lt;/strong&gt; (date-fns today) and make conversions explicit. &lt;strong&gt;Watch Temporal&lt;/strong&gt; and adopt it as it lands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect DST and "today."&lt;/strong&gt; A day isn't always 24 hours, and "today" is always somebody's local today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serialize as ISO 8601 UTC.&lt;/strong&gt; Unambiguous on the wire; humanize only at the edge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason time bugs are so embarrassing is that they're invisible right up until they're not. Everything works on your machine, in your timezone, during the months without a DST transition — and then a user in Tokyo, or a clock change in November, or a "today" filter computed in the wrong zone quietly serves the wrong answer, and it looks like the code was never tested. It was tested. It was just tested in one timezone. Treating time as an absolute UTC fact that you view through a local lens only at the very end is the habit that makes those 2 AM bugs stop being yours.&lt;/p&gt;

&lt;p&gt;Next, a different kind of unglamorous-but-treacherous: &lt;a href="https://ma-x.im/blog/react-playbook-file-upload" rel="noopener noreferrer"&gt;file uploads in React&lt;/a&gt; — progress, chunking, and the surprisingly deep problem of moving a large file from a browser to somewhere it can live.&lt;/p&gt;

&lt;p&gt;If you've got a date that shifts by a day, a badge that says "today" when it isn't, or a meeting an hour off for some users, that's almost always a UTC-vs-local leak. Tell me where the wrong value shows up and I'll tell you which conversion is doing it.&lt;/p&gt;

</description>
      <category>react</category>
      <category>dates</category>
      <category>time</category>
      <category>reactplaybook</category>
    </item>
    <item>
      <title>Payments in React: The Client Never Decides You Got Paid</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/payments-in-react-the-client-never-decides-you-got-paid-13jh</link>
      <guid>https://dev.to/maximusft/payments-in-react-the-client-never-decides-you-got-paid-13jh</guid>
      <description>&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%2F97xrbnr76awkm3udammr.png" 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%2F97xrbnr76awkm3udammr.png" alt="Payments in React: The Client Never Decides You Got Paid" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the surface, taking a payment looks like the most ordinary thing in the world: a form with some fields, a submit button, a success message. You've built a hundred forms. This is just a form where one of the fields happens to be a card number.&lt;/p&gt;

&lt;p&gt;It is not just a form, and the card number is exactly why. That field is radioactive. The moment a real card number touches your servers, you've walked into PCI compliance — a wall of security obligations that exists for very good reasons and that you do not want to be responsible for. And the "success message" hides the second trap: unlike every other form you've built, you don't actually know the outcome when the user clicks submit. The payment confirms &lt;em&gt;asynchronously&lt;/em&gt;, somewhere you can't see, and the single most expensive mistake in this entire domain is letting your React app believe it got paid because a client-side callback said so.&lt;/p&gt;

&lt;p&gt;So this article isn't a Stripe tutorial. It's about the two lines that matter — the one that keeps the card number away from you, and the one that decides where "you got paid" is actually true — because getting those two lines right is the whole job, and getting either wrong is how you end up either liable or robbed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Line: Never Touch the Card Number
&lt;/h2&gt;

&lt;p&gt;Start with the rule that shapes everything else: &lt;strong&gt;the raw card number must never reach your server.&lt;/strong&gt; Not to "just pass it through," not to log it, not for a millisecond. The instant it does, you're in PCI-compliance scope, and that is a burden measured in audits and dread, not code.&lt;/p&gt;

&lt;p&gt;The whole architecture of a modern payments provider exists to keep you out of that scope, and the mechanism is worth understanding because it's genuinely clever. You don't build the card input. Stripe does — via &lt;a href="https://stripe.com/docs/payments/elements" rel="noopener noreferrer"&gt;Stripe Elements&lt;/a&gt;, an iframe-based field that Stripe hosts and controls. The card number is typed into &lt;em&gt;their&lt;/em&gt; iframe, on &lt;em&gt;your&lt;/em&gt; page, and it goes straight to Stripe. Your JavaScript can't read it. Your server never sees it. What you get back instead is a token — a harmless reference that means "Stripe is holding a card for you" — and &lt;em&gt;that&lt;/em&gt; is what flows through your system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PaymentElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useStripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useElements&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stripe/react-stripe-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CheckoutForm&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStripe&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;elements&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useElements&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FormEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;stripe&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Stripe collects the card in its own iframe and confirms directly.&lt;/span&gt;
    &lt;span class="c1"&gt;// The card number never touches your JS or your server.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirmPayment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;confirmParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;return_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/order/complete&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Show the user the reason. This is the "did it start" answer,&lt;/span&gt;
      &lt;span class="c1"&gt;// NOT the "did it succeed" answer.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&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;PaymentElement&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pay&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what your code never handles: the card number itself. &lt;code&gt;&amp;lt;PaymentElement /&amp;gt;&lt;/code&gt; is Stripe's iframe living inside your form. You render it, you style around it, and you stay completely out of scope for the one piece of data that would otherwise make you liable. This is the &lt;a href="https://ma-x.im/blog/react-playbook-authentication" rel="noopener noreferrer"&gt;buy-don't-build&lt;/a&gt; principle from the auth article at its most extreme — the risk here is so high that building it yourself isn't a trade-off, it's a mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Line: The Client Doesn't Decide
&lt;/h2&gt;

&lt;p&gt;Now the subtler and more dangerous part. The payment is initiated in the browser, but the browser is the last place that should decide whether it &lt;em&gt;succeeded&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here's why this bites people. The naive flow: user pays, Stripe's client call resolves, you show "Payment successful!" and mark the order as paid in your UI. It demos perfectly. And it's exploitable and unreliable, because the client is an environment you don't control. The user closes the tab the instant before the callback fires — the card was charged, your app never knew. The network drops after the charge but before the response — same. And a hostile user can straight up call your "mark as paid" endpoint without paying at all, because anything the client asserts, a client can fake.&lt;/p&gt;

&lt;p&gt;The answer is the one that runs through this whole series: &lt;strong&gt;the client is untrusted; the server is the source of truth.&lt;/strong&gt; The real confirmation doesn't come from your React callback. It comes from Stripe, server-to-server, via a &lt;strong&gt;webhook&lt;/strong&gt; — Stripe calls &lt;em&gt;your backend&lt;/em&gt; to say "this payment actually succeeded," and that server-side event is the only thing allowed to mark an order as paid.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On your server — the ONLY place an order becomes "paid".&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;req&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// proves the call really came from Stripe&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment_intent.succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markOrderPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// trusted truth&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the client's job and the server's job split cleanly. The client &lt;em&gt;starts&lt;/em&gt; the payment and shows a hopeful "we're processing your order." The server, via the webhook, &lt;em&gt;confirms&lt;/em&gt; it and flips the order to paid. Those are two different facts arriving through two different channels, and conflating them — treating the client's optimistic result as the settled truth — is the bug that gets apps double-charging, under-charging, or handing out product for free.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The client can tell you a payment &lt;em&gt;started&lt;/em&gt;. Only the server, via the provider's webhook, can tell you it &lt;em&gt;succeeded&lt;/em&gt;. Never let the browser be the thing that says "paid."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This maps exactly onto the &lt;a href="https://ma-x.im/blog/react-playbook-authentication" rel="noopener noreferrer"&gt;server-state idea from the auth article&lt;/a&gt;: the important truth lives on the server, and the client holds a hopeful, possibly-stale view of it. Payments are the highest-stakes version of that same pattern, which is why the discipline matters more here than anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  So How Hard Is It, Really?
&lt;/h2&gt;

&lt;p&gt;Honestly? Less than the fear suggests, &lt;em&gt;if&lt;/em&gt; you respect the two lines. Stripe has done the genuinely hard parts — the card handling, the PCI scope, the fraud tooling. What's left for you is smaller than people expect, but it's unforgiving about correctness.&lt;/p&gt;

&lt;p&gt;Your real work is three things. Render Stripe's Element so the card never touches you — mechanical, and Stripe hands you the components. Kick off the payment from the client and, critically, treat the client-side result as "started," not "done." And build the webhook handler on your backend as the sole authority that marks an order paid, verifying the signature so nobody can forge that call. That's the shape of it. It's not a huge amount of code. It's a small amount of code where being casually wrong costs actual money, which is a very different kind of hard than "lots of code."&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The card number is radioactive.&lt;/strong&gt; Use Stripe Elements so it never touches your JS or server. Don't negotiate with PCI scope — avoid it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The client starts the payment; it does not confirm it.&lt;/strong&gt; Show "processing," never "paid," from a client callback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The webhook is the source of truth.&lt;/strong&gt; An order becomes paid on your server, when Stripe tells your server it succeeded — and never before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the webhook signature.&lt;/strong&gt; Otherwise anyone can call your endpoint and claim they paid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buy, don't build.&lt;/strong&gt; This is the domain where rolling your own is not clever, it's negligent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason payments feel scarier than they are is that they punish the exact habit web development otherwise rewards: trusting the client. Everywhere else, an optimistic UI that assumes success is good UX. Here, an optimistic UI that &lt;em&gt;records&lt;/em&gt; success is a liability. Once you internalize that the browser can only ever say "I tried" and the server is the only thing that can say "it worked," the whole domain stops being frightening and becomes just careful — two clean lines, respected every time.&lt;/p&gt;

&lt;p&gt;The next article stays with the values that are deceptively easy to get wrong and quietly ruin things: &lt;a href="https://ma-x.im/blog/react-playbook-time" rel="noopener noreferrer"&gt;time and dates in React&lt;/a&gt; — timezones, storage, and the bugs that only surface at the worst possible hour.&lt;/p&gt;

&lt;p&gt;If you've got a checkout that marks orders paid from a client callback, that's the second line crossed, and it's worth fixing before it costs you. Tell me how your success path works and I'll tell you where the hole is.&lt;/p&gt;

</description>
      <category>react</category>
      <category>payments</category>
      <category>stripe</category>
      <category>security</category>
    </item>
    <item>
      <title>Internationalization Isn't Translation: What Actually Breaks</title>
      <dc:creator>Maksym Kuzmitskyi (MaximusFT)</dc:creator>
      <pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/maximusft/internationalization-isnt-translation-what-actually-breaks-3jc8</link>
      <guid>https://dev.to/maximusft/internationalization-isnt-translation-what-actually-breaks-3jc8</guid>
      <description>&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%2Faquxuu0qkbu8c43f21yl.png" 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%2Faquxuu0qkbu8c43f21yl.png" alt="Internationalization Isn't Translation: What Actually Breaks" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ask most developers what internationalization means and you'll get the same answer: instead of writing "Save" in your JSX, you look up a key in a dictionary and get "Save" or "Enregistrer" depending on the user's language. And they're right — that &lt;em&gt;is&lt;/em&gt; part of it. It's also the easy 10%, and it's the part that makes people underestimate the whole thing.&lt;/p&gt;

&lt;p&gt;Because a translated string is not a translated interface. The moment you actually ship to another language, the interesting failures show up, and none of them are about the words. A date renders as &lt;code&gt;7/18/2026&lt;/code&gt; to an American and &lt;code&gt;18/07/2026&lt;/code&gt; to nearly everyone else. A price needs a different currency symbol, in a different position, with a comma where you had a period. "1 item" versus "5 items" is trivial in English and a genuine grammar problem in languages with six plural forms. And in Arabic or Hebrew the entire layout flips direction. None of that is a dictionary lookup.&lt;/p&gt;

&lt;p&gt;So this article is about the 90% — what internationalizing a React app actually involves once you stop thinking of it as translation and start thinking of it as &lt;em&gt;formatting and loading&lt;/em&gt;, which is what it really is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reframe: It's Formatting, Not Words
&lt;/h2&gt;

&lt;p&gt;Here's the shift that made i18n click for me. Stop picturing a dictionary. Picture a formatting engine.&lt;/p&gt;

&lt;p&gt;The hardest parts of internationalization aren't the strings you wrote — they're the &lt;em&gt;values&lt;/em&gt; you render. A date is a value that has to be formatted for a locale. A number is a value. A currency amount is a value. A count that decides between "item" and "items" is a value running through a locale's plural rules. The English strings are static and easy; the dynamic values are where every locale disagrees, and where a naive app quietly produces wrong output that looks fine to you and broken to them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Translation handles the words you wrote. Internationalization handles the values you render. The second one is the hard part, and it's the part people skip.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The good news is you don't implement any of this yourself. The browser ships a full formatting engine — the &lt;code&gt;Intl&lt;/code&gt; API — and the React libraries are wrappers that make it ergonomic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formatting Values the Right Way
&lt;/h2&gt;

&lt;p&gt;The instinct, when you need a date or a price in the UI, is to build the string by hand — grab the month, slap it together, append a currency symbol. That instinct is exactly the bug, because the &lt;em&gt;format itself&lt;/em&gt; is locale-dependent, not just the numbers inside it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Intl&lt;/code&gt; API already knows every locale's rules. You give it a value and a locale; it gives you correct output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Dates: same instant, formatted for the user's locale.&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "7/18/2026"&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "18.7.2026"&lt;/span&gt;

&lt;span class="c1"&gt;// Currency: symbol, position, and separators all differ by locale.&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&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;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1234.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "$1,234.50"&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de-DE&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;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1234.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "1.234,50 €"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what you're &lt;em&gt;not&lt;/em&gt; deciding: where the currency symbol goes, whether the thousands separator is a comma or a period, the order of day and month. You hand over the value and the locale and get correct output. Building any of that by hand isn't just more code — it's code that's confidently wrong in locales you don't personally read.&lt;/p&gt;

&lt;p&gt;Then there's pluralization, which English makes look trivial and other languages do not. "1 item" / "2 items" is a two-case rule; Polish and Arabic have far more, and a hardcoded &lt;code&gt;count === 1 ? 'item' : 'items'&lt;/code&gt; is simply broken there. This is why the string libraries use the ICU message format, which encodes plural rules per locale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight fluent"&gt;&lt;code&gt;&lt;span class="err"&gt;{&lt;/span&gt;&lt;span class="no"&gt;count&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;plural, one &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;item&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; other &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;items&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You write the rule once; the library picks the right form for whatever locale is active, using the same &lt;code&gt;Intl&lt;/code&gt; plural data. You are never in the business of knowing Polish grammar — you're in the business of not hardcoding English grammar.&lt;/p&gt;

&lt;h2&gt;
  
  
  The React Side: Loading, Not Bundling
&lt;/h2&gt;

&lt;p&gt;Now the part that's specifically a React and architecture problem, and the one that separates a demo from a real app: &lt;strong&gt;you cannot ship every language in your bundle.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the trap. You wire up &lt;a href="https://react.i18next.com" rel="noopener noreferrer"&gt;react-i18next&lt;/a&gt; or &lt;a href="https://formatjs.io" rel="noopener noreferrer"&gt;FormatJS&lt;/a&gt;, it works, and to make it work you import all your translation files at the top. Now every user downloads English, French, German, Japanese, and Arabic just to see the one language they actually use. Your bundle balloons with text 90% of users will never read, and it gets worse with every locale you add. A feature meant to serve more users made the app slower for all of them.&lt;/p&gt;

&lt;p&gt;The right shape is to treat translation data as something you &lt;em&gt;load on demand&lt;/em&gt;, the same way you'd &lt;a href="https://ma-x.im/blog/react-playbook-code-structure" rel="noopener noreferrer"&gt;code-split&lt;/a&gt; a route. The active locale's messages load when needed; the rest stay on the server until someone actually switches:&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;// Load only the active locale's messages, not all of them.&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;loadMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`../locales/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&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 dynamic &lt;code&gt;import&lt;/code&gt; is the whole difference. A user on French downloads French. Switch to German and German loads then — a small, one-time fetch — instead of everyone paying for every language up front. The i18n libraries support this directly; the mistake is not the library, it's statically importing everything because the demo did.&lt;/p&gt;

&lt;p&gt;This is the same instinct the whole series keeps returning to: the naive version loads everything eagerly, and the production version loads what's needed when it's needed. Translations are just data, and data that most users won't touch belongs behind a lazy load.&lt;/p&gt;

&lt;h2&gt;
  
  
  One More Thing: Direction
&lt;/h2&gt;

&lt;p&gt;A short but real one, because it's the part that turns a "translated" app back into an untranslated-looking one. Some languages — Arabic, Hebrew, Persian — read right-to-left, and that's not a font setting, it's a layout inversion. Your sidebar moves to the other side, your icons mirror, your text aligns the other way.&lt;/p&gt;

&lt;p&gt;The workable answer is to stop thinking in "left" and "right" and start thinking in "start" and "end." Modern CSS logical properties — &lt;code&gt;margin-inline-start&lt;/code&gt; instead of &lt;code&gt;margin-left&lt;/code&gt;, &lt;code&gt;padding-inline-end&lt;/code&gt; instead of &lt;code&gt;padding-right&lt;/code&gt; — flip automatically with the document direction. Set &lt;code&gt;dir="rtl"&lt;/code&gt; on the root for RTL locales and a layout built on logical properties reorients itself. Build it on hardcoded &lt;code&gt;left&lt;/code&gt;/&lt;code&gt;right&lt;/code&gt; and you get to audit every component by hand. It's cheap if you do it from the start and expensive if you retrofit — which is a good reason to reach for logical properties by default even in a single-language app.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd Approach It
&lt;/h2&gt;

&lt;p&gt;Strip it to decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's formatting, not translation.&lt;/strong&gt; The strings are easy; the values — dates, numbers, currency, plurals — are where locales actually disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never format values by hand.&lt;/strong&gt; Use &lt;code&gt;Intl&lt;/code&gt; (via your i18n library). It already knows every locale's rules; you never do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use ICU message format for plurals.&lt;/strong&gt; Don't hardcode English's two-case grammar into a multi-locale app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load locales on demand, don't bundle them all.&lt;/strong&gt; Dynamic-import the active language; leave the rest on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build layout on logical properties.&lt;/strong&gt; &lt;code&gt;start&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt;, not &lt;code&gt;left&lt;/code&gt;/&lt;code&gt;right&lt;/code&gt;, so RTL is a setting and not a rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used to think of i18n as a chore you tack on at the end — extract the strings, hand them to translators, done. That framing is why so many "internationalized" apps still show a German user an American date and an Arabic user a left-to-right layout. The strings were never the hard part. The hard part is that every locale formats values differently and reads in its own direction, and the app has to be built to bend to that instead of assuming the world works like the machine you developed on. Get the formatting and loading right and translation is the small, boring finish — which is exactly where it should sit.&lt;/p&gt;

&lt;p&gt;Speaking of values that quietly differ everywhere and ruin your day when you get them wrong — the next article is about &lt;a href="https://ma-x.im/blog/react-playbook-payments" rel="noopener noreferrer"&gt;handling money in React&lt;/a&gt;: what taking a payment actually requires, and why the client should never be the thing that decides you got paid.&lt;/p&gt;

&lt;p&gt;If you've got an app that "supports" multiple languages but still leaks the developer's locale — an English date here, a broken plural there — that's the formatting-not-translation gap. Tell me what's showing wrong and I'll point you at which piece is missing.&lt;/p&gt;

</description>
      <category>react</category>
      <category>i18n</category>
      <category>internationalization</category>
      <category>reactplaybook</category>
    </item>
  </channel>
</rss>
