<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>ma-x.im — Maksym Kuzmitskyi</title>
    <link>https://ma-x.im</link>
    <description>Thoughts on frontend architecture, React patterns, and building systems that scale.</description>
    <language>en</language>
    <atom:link href="https://ma-x.im/feed.xml" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Fri, 17 Jul 2026 23:11:46 GMT</lastBuildDate>
    
    <item>
      <title>Optimistic UI: Updating Before the Server Says Yes</title>
      <link>https://ma-x.im/blog/react-playbook-optimistic-ui</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-optimistic-ui</guid>
      <description>Optimistic UI is a small lie: you show the result before the server confirms it. Done right, the app feels instant. Done wrong, the UI quietly tells the user something that never happened. The difference is entirely in how you roll back.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-optimistic-ui/cover.png" alt="Optimistic UI: Updating Before the Server Says Yes" /></p>
<p>At the end of the <a href="https://ma-x.im/blog/react-playbook-data-normalization">last article</a> I said that once you hold one coherent copy of server state and can invalidate it precisely, a door opens that used to be terrifying: updating the UI <em>before</em> the server confirms, and rolling back cleanly if it rejects. That&#39;s optimistic UI, and I want to be honest about what it actually is before we make it feel magic.</p>
<p>Optimistic UI is a lie. A small, well-intentioned, usually-correct lie — but a lie. You click the star, and the star fills in <em>immediately</em>, before the server has any idea you did anything. You&#39;re betting the request will succeed and showing the user the future you expect. Most of the time you&#39;re right and the app feels instant. The entire craft is in what happens the rare time you&#39;re wrong.</p>
<h2>Why we bother lying at all</h2>
<p>The alternative is honest and awful. User clicks, you show a spinner, you wait for the round trip, then you update. On a good connection that&#39;s 200-400ms of the UI sitting there doing nothing after a click that should have felt instant. On a train, it&#39;s two seconds of a spinning star. For a like button, a toggle, a reorder — high-frequency, low-stakes actions — that latency is the whole experience, and it makes a fast app feel slow.</p>
<p>So we front-run the server. The user&#39;s intent is almost always going to succeed, so we render success now and reconcile with reality when it arrives. The feeling is immediate; the correctness catches up a beat later.</p>
<blockquote>
<p>Optimistic UI is a promise to the user: &quot;I&#39;ll show you this now, and I guarantee I&#39;ll fix it if I was wrong.&quot; If you can&#39;t keep the second half, don&#39;t make the first.</p>
</blockquote>
<h2>The dangerous naive version</h2>
<p>Here&#39;s the version I see most often, and it&#39;s the one that ships bugs:</p>
<pre><code class="language-tsx">// DON&#39;T: optimism with no way back
function useToggleStar(id: number) {
  const [starred, setStarred] = useState(false);
  const toggle = async () =&gt; {
    setStarred((s) =&gt; !s);        // update immediately
    await api.setStar(id, !starred); // hope it works
  };
  return { starred, toggle };
}
</code></pre>
<p>Read what happens when <code>api.setStar</code> throws. The state already flipped. There&#39;s no snapshot, no catch, no rollback. The star stays filled, the server never recorded it, and the user walks away believing they starred something they didn&#39;t. This isn&#39;t a cosmetic bug — the UI is now actively lying with no plan to stop. Optimism without rollback isn&#39;t optimistic UI. It&#39;s just a race you&#39;re pretending you won.</p>
<h2>The real shape: snapshot, apply, reconcile</h2>
<p>A correct optimistic update always has the same four beats. Cancel anything in flight that could clobber you. Snapshot the current truth. Apply the optimistic change. Then, when the request settles, either keep it (success) or restore the snapshot (failure). Because this series uses <a href="https://ma-x.im/blog/react-playbook-data-fetching">TanStack Query</a>, the cache is where the truth lives, and the mutation lifecycle gives you exactly these hooks:</p>
<pre><code class="language-tsx">const toggleStar = useMutation({
  mutationFn: (id: number) =&gt; api.toggleStar(id),

  onMutate: async (id) =&gt; {
    // 1. stop in-flight refetches so they can&#39;t overwrite our optimistic value
    await queryClient.cancelQueries({ queryKey: [&#39;todo&#39;, id] });

    // 2. snapshot the current truth so we can roll back
    const previous = queryClient.getQueryData&lt;Todo&gt;([&#39;todo&#39;, id]);

    // 3. apply the optimistic change to the cache
    queryClient.setQueryData&lt;Todo&gt;([&#39;todo&#39;, id], (old) =&gt;
      old ? { ...old, starred: !old.starred } : old,
    );

    // hand the snapshot to onError via context
    return { previous };
  },

  onError: (_err, id, context) =&gt; {
    // 4a. reality rejected us — restore exactly what we had
    if (context?.previous) {
      queryClient.setQueryData([&#39;todo&#39;, id], context.previous);
    }
  },

  onSettled: (_data, _err, id) =&gt; {
    // 4b. reconcile with the server&#39;s version, success or failure
    queryClient.invalidateQueries({ queryKey: [&#39;todo&#39;, id] });
  },
});
</code></pre>
<p>Every line earns its place. <code>cancelQueries</code> is the one people skip and then can&#39;t explain the flicker: if a background refetch is already in flight, it will land <em>after</em> your optimistic write and stomp it with stale data. Snapshotting into <code>context</code> is what makes rollback surgical — you restore the exact previous object, not a guess. And <code>onSettled</code> invalidating is the humility step: whatever we optimistically showed, the server&#39;s answer is the real one, so we refetch and let truth win. This is why the <a href="https://ma-x.im/blog/react-playbook-data-normalization">previous article&#39;s</a> precise invalidation mattered — optimistic UI is built directly on top of it.</p>
<h2>Reconciliation is not &quot;did it work&quot; — it&#39;s &quot;what&#39;s true now&quot;</h2>
<p>The subtle part is the reconcile step, because the world is not just success-or-failure. Sometimes the request succeeds but the server&#39;s result differs from what you optimistically drew. You optimistically added an item at the top of a list; the server inserts it sorted, so it&#39;s actually third. You optimistically set a counter to 5; someone else&#39;s change already made it 7.</p>
<p>The rule that keeps you sane: <strong>the server is the source of truth, and reconciliation means adopting its version, not defending yours.</strong> Your optimistic value was a placeholder to make the UI feel alive, nothing more. When the real data arrives, you replace the placeholder — even if it means the item you just &quot;added to the top&quot; visibly jumps to its sorted position. That tiny correction is honest and fine. Fighting to keep your optimistic guess because it looks smoother is how you end up with a client that slowly drifts out of sync with the backend.</p>
<h2>Where it belongs, and where the lie gets expensive</h2>
<p>I reach for optimistic UI when the action is cheap, reversible, and frequent, and when being wrong is cheap to correct. Toggling a star, liking a post, checking a checkbox, reordering a list, adding a comment. If the request fails, the rollback is a small, understandable flicker, and the user retries. The upside — instant feel — is huge and constant; the downside is rare and mild.</p>
<p>I do <em>not</em> reach for it when a wrong guess is expensive or confusing. Payments are the obvious one: never optimistically show &quot;Payment successful.&quot; Showing success for a charge that then fails is worse than any spinner. Same for anything irreversible, anything with legal or money weight, and anything where the rollback would be more jarring than the wait — imagine optimistically navigating someone into a screen that then vanishes because the action failed. In those cases the honest spinner is the right call. The question isn&#39;t &quot;can I make this optimistic?&quot; It&#39;s &quot;if I&#39;m wrong, is the correction cheap and clear?&quot; When the answer is no, don&#39;t tell the lie.</p>
<h2>The reframe</h2>
<p>What optimistic UI really taught me is that a good client models <em>intent</em> and <em>confirmed truth</em> as two different things, and knows how to move between them. The optimistic value is intent rendered early. The server response is confirmed truth. Reconciliation is the bridge. Once you hold those as separate ideas, the whole pattern stops feeling like a hack and starts feeling like what it is: a disciplined way to decouple how fast the UI feels from how fast the network actually is.</p>
<p>And notice what the rollback in <code>onError</code> really was — a single-step reversal back to a snapshot you took a moment ago. That works beautifully for one action. But the moment users want to reverse a <em>sequence</em> of actions — undo the last five edits in an editor, then redo two of them — a lone snapshot isn&#39;t enough. You need a history of reversible changes, which is a different and genuinely interesting problem. That&#39;s undo/redo, and it&#39;s next.</p>
<p>If you&#39;ve got an optimistic update in your app right now, do me a favor and check one thing: what happens on <code>onError</code>? If you can&#39;t point to the line that restores the previous value, you don&#39;t have optimistic UI — you have a lie with no exit. Tell me what you find.</p>
]]></content:encoded>
      <pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Server State Is a Cache: Normalization, Duplicates, and Invalidation You Can Trust</title>
      <link>https://ma-x.im/blog/react-playbook-data-normalization</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-data-normalization</guid>
      <description>The same customer shows up in a list, a detail page, and a header badge — three copies of one truth you don&apos;t own. Edit one and the others go stale. Here&apos;s when to normalize server data into one source of truth, when the query cache is enough, and how to invalidate without nuking everything.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-data-normalization/cover.png" alt="Server State Is a Cache: Normalization, Duplicates, and Invalidation You Can Trust" /></p>
<p>The <a href="https://ma-x.im/blog/react-playbook-local-storage-and-state">previous article</a> drew a hard line: state the client owns versus data it doesn&#39;t. Everything there was about the stuff you own — theme, drafts, preferences — where you are the source of truth and storage just remembers it. This one is about the other side of that line, and it&#39;s where a lot of otherwise-clean apps quietly turn to mush.</p>
<p>Here&#39;s the situation. You fetch a list of customers for a table. You fetch one customer for a detail page. There&#39;s a little avatar-and-name badge in the header showing the currently selected customer. That&#39;s three network responses. And customer <code>#42</code> appears in all three. You now have three copies of one entity in memory, and — this is the part that bites — nothing keeps them in agreement. Edit the customer&#39;s name on the detail page, and the table still shows the old name until something refetches it. The badge shows a third version. Your UI is now lying to the user, and the bug report says &quot;the name didn&#39;t update,&quot; which tells you nothing about why.</p>
<p>This is the server-state problem, and it is fundamentally different from the local-state problem. You don&#39;t own this data. It has an owner on the backend. Every copy you hold is a <em>cache</em> of a truth that lives somewhere else and can change without telling you.</p>
<h2>The duplication trap</h2>
<p>The instinct is to treat each fetch as its own little pile of data. List query returns an array, you render it. Detail query returns an object, you render it. Header does its own thing. Each is correct in isolation. The problem is that the same entity is now stored in multiple unrelated places, and a write to one has no path to the others.</p>
<blockquote>
<p>The bug isn&#39;t that data goes stale. It&#39;s that you have more than one copy of the same truth, and no single place responsible for it.</p>
</blockquote>
<p>There are two honest ways out, and picking the wrong one is where teams either over-engineer or under-engineer for years.</p>
<h2>Option one: normalize into one source of truth</h2>
<p>Normalization is the database idea, moved into the client. Instead of storing whatever shape each response happened to have, you break responses into <em>entities keyed by id</em>, store each entity exactly once, and have every view reference it by id.</p>
<pre><code class="language-ts">// denormalized — the same customer duplicated across responses
const listResponse = [{ id: 42, name: &#39;Acme Corp&#39;, tier: &#39;gold&#39; }, /* ... */];
const detailResponse = { id: 42, name: &#39;Acme Corp&#39;, tier: &#39;gold&#39;, notes: &#39;...&#39; };

// normalized — one customer, referenced by id
const cache = {
  customers: {
    42: { id: 42, name: &#39;Acme Corp&#39;, tier: &#39;gold&#39;, notes: &#39;...&#39; },
  },
  // the list is now just an ordered array of ids into the store above
  customerList: [42, 17, 8],
};
</code></pre>
<p>Now there is exactly one <code>customers[42]</code>. The table reads <code>customerList</code> and looks each id up. The detail page reads <code>customers[42]</code>. The header badge reads <code>customers[42]</code>. Update that one record and every view re-reads the same object — they can&#39;t disagree, because there&#39;s nothing to disagree with. This is what normalized caches like RTK Query, Apollo, and the <code>normalizr</code>-style approach give you: a mini in-memory database where entities live once and views are just queries into it.</p>
<p>The cost is real, though, and it&#39;s why I don&#39;t reach for it by default. You need an identity for everything (what&#39;s the id? what about entities without one?), you need to teach the cache how to normalize each response shape, and nested relationships get fiddly. You&#39;ve essentially adopted a client-side ORM. For a CRM-scale app with heavy entity reuse and live updates, that&#39;s a good trade. For most apps, it&#39;s a lot of machinery for a problem you can solve more cheaply.</p>
<h2>Option two: let the query cache own it, invalidate precisely</h2>
<p>The cheaper answer — and the one this series defaults to, because it uses <a href="https://ma-x.im/blog/react-playbook-data-fetching">TanStack Query</a> — is a <em>document</em> cache rather than a normalized one. Data is cached per query key, not per entity. <code>[&#39;customers&#39;]</code> is one cache entry; <code>[&#39;customer&#39;, 42]</code> is another. There genuinely are two copies of customer <code>#42</code>. The trick is that you don&#39;t try to keep them manually in sync — you make the cache re-fetch the affected keys after a change, and you do it <em>surgically</em>.</p>
<p>The blunt instrument everyone reaches for first:</p>
<pre><code class="language-ts">// after editing a customer — refetch literally everything customer-related
await queryClient.invalidateQueries();
</code></pre>
<p>This &quot;works&quot; and it&#39;s a performance and UX disaster. Every table, every dropdown, every cached query on the screen refetches, spinners bloom everywhere, and you&#39;ve turned one edit into a network storm. Invalidation is not a hammer. It&#39;s a scalpel:</p>
<pre><code class="language-ts">const mutation = useMutation({
  mutationFn: updateCustomer,
  onSuccess: (updated) =&gt; {
    // 1. patch the detail cache directly — no refetch needed, we have the data
    queryClient.setQueryData([&#39;customer&#39;, updated.id], updated);

    // 2. mark just the list stale, so it refetches when next observed
    queryClient.invalidateQueries({ queryKey: [&#39;customers&#39;], exact: true });
  },
});
</code></pre>
<p>Two precise moves. <code>setQueryData</code> writes the fresh entity straight into the detail cache, so the detail page updates instantly with zero network. <code>invalidateQueries</code> with a specific key marks only the list as stale — and critically, TanStack Query won&#39;t even refetch it until something is actually looking at it. Nothing else on the page moves. That precision is the whole skill. The library gives you a scalpel; most bugs come from swinging it like a hammer.</p>
<h2>The mental shift: query keys are your schema</h2>
<p>Here&#39;s the reframe that made this click for me. In a normalized cache, your entity ids <em>are</em> the schema — everything hangs off <code>customers[42]</code>. In a document cache, your <strong>query keys</strong> play that role. The structure and discipline of your query keys is what makes precise invalidation possible or impossible.</p>
<p>If your keys are ad hoc — <code>[&#39;customers&#39;]</code> here, <code>[&#39;customerList&#39;, filters]</code> there, <code>[&#39;allCustomers&#39;]</code> in a third place — you can&#39;t invalidate precisely because you can&#39;t <em>describe</em> the set of things that went stale. But if keys are hierarchical and consistent, invalidation becomes expressive:</p>
<pre><code class="language-ts">// a consistent, hierarchical key factory
const customerKeys = {
  all: [&#39;customers&#39;] as const,
  lists: () =&gt; [...customerKeys.all, &#39;list&#39;] as const,
  list: (filters: Filters) =&gt; [...customerKeys.lists(), filters] as const,
  details: () =&gt; [...customerKeys.all, &#39;detail&#39;] as const,
  detail: (id: number) =&gt; [...customerKeys.details(), id] as const,
};

// invalidate every customer list, regardless of filters, and nothing else
queryClient.invalidateQueries({ queryKey: customerKeys.lists() });
</code></pre>
<p>Because TanStack Query matches keys by prefix, <code>customerKeys.lists()</code> invalidates every filtered list variant in one call without touching a single detail cache. The key factory turns &quot;which things are now stale&quot; into a sentence you can write. This is the same instinct as a good <a href="https://ma-x.im/blog/react-playbook-module-dependencies">public API or module boundary</a>: a little structure up front makes the operations you&#39;ll do a thousand times both possible and safe.</p>
<h2>How to actually choose</h2>
<p>I&#39;ll be direct, because this is where people waste the most effort. Reach for a <strong>normalized cache</strong> when three things are true together: the same entities are reused heavily across many views, they update frequently (real-time, collaborative, or lots of mutations), and staleness between views is genuinely user-visible and unacceptable. Trading dashboards, chat apps, collaborative editors, big CRMs — normalization earns its keep there.</p>
<p>For everything else — which is most apps — a <strong>document cache with disciplined query keys and precise invalidation</strong> is not a compromise, it&#39;s the right call. It has less machinery, less to learn, fewer ways to corrupt the cache, and it scales further than people expect. Honestly, the number of teams who adopted a full normalized cache and then used it like a document cache anyway is not small. Start with the simpler model and let real pain — not anticipated pain — push you to the heavier one.</p>
<p>Either way, the principle underneath both is identical, and it&#39;s the thing to actually remember: <strong>there is one truth, it lives on the server, and your job is to hold one coherent copy of it — not five copies you hope agree.</strong> Normalization enforces that with entity ids. A query cache enforces it with keys and invalidation. Pick your mechanism, but don&#39;t skip the principle, because &quot;the name didn&#39;t update&quot; is what skipping it looks like in the bug tracker.</p>
<p>Once you&#39;ve got one coherent copy and precise invalidation, a door opens that used to be terrifying: updating the UI <em>before</em> the server confirms, and rolling back cleanly if it rejects. That&#39;s optimistic UI, and it&#39;s only sane once your cache has a single source of truth to optimistically edit and a precise way to reconcile when the truth comes back. That&#39;s next.</p>
<p>If your app has a &quot;why is this showing the old value&quot; bug right now, I&#39;d bet it&#39;s a duplication problem wearing a staleness costume. Tell me where the copies live — list and detail, cache and store, two tabs — and I&#39;ll tell you which of these two models you actually needed.</p>
]]></content:encoded>
      <pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>localStorage Is Not State: Persistence, Hydration, and Tabs That Fight Each Other</title>
      <link>https://ma-x.im/blog/react-playbook-local-storage-and-state</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-local-storage-and-state</guid>
      <description>localStorage looks like a global variable that survives a refresh. That framing is the bug. Treat it as a persistence layer that mirrors your state — not as the state itself — and the hydration mismatches, stale tabs, and schema breakages mostly disappear.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-local-storage-and-state/cover.png" alt="localStorage Is Not State: Persistence, Hydration, and Tabs That Fight Each Other" /></p>
<p>At the end of the <a href="https://ma-x.im/blog/react-playbook-ui-kit-versioning">last article</a> I said I wanted to start this run with the deceptively simple persistence question: what belongs in localStorage, what belongs in state, and what breaks when you pretend they&#39;re the same thing. It sounds like a beginner question. It isn&#39;t. I&#39;ve seen senior codebases get this wrong in ways that produce flickering UIs, tabs that overwrite each other, and a crash the first time someone ships a new version of the app.</p>
<p>The root mistake is almost always the same, and it&#39;s a framing problem. <code>localStorage</code> looks like a global variable that happens to survive a refresh. <code>localStorage.getItem(&#39;theme&#39;)</code> reads like <code>window.theme</code>. So people start treating it as a place to <em>keep</em> state — reading from it in render, writing to it inline, scattering <code>getItem</code>/<code>setItem</code> across components. That framing is the bug. Everything downstream goes wrong because of it.</p>
<h2>Three concerns people mash into one</h2>
<p>When someone says &quot;I&#39;ll just store it in localStorage,&quot; they&#39;re usually blending three separate things that have nothing to do with each other:</p>
<p><strong>Source of truth</strong> — where the current value lives <em>right now</em>, in memory, reactive, driving the UI. That&#39;s your state (a store atom, context, <code>useState</code>).</p>
<p><strong>Persistence</strong> — how that value survives a reload. That&#39;s storage: localStorage, IndexedDB, a cookie.</p>
<p><strong>Cross-tab sync</strong> — how two open tabs agree on the value. That&#39;s a messaging problem.</p>
<p>localStorage can only honestly do the middle one. It is not reactive — writing to it doesn&#39;t re-render anything. It is synchronous and string-only. And it&#39;s shared across tabs in a way that will surprise you. The moment you ask it to be your source of truth, you&#39;ve handed the hardest job to the tool least suited for it.</p>
<blockquote>
<p>Storage is a mirror of your state, not the state itself. The state lives in memory and drives the UI; storage just remembers what it was so you can restore it later.</p>
</blockquote>
<p>Once that sentence is the rule, the architecture falls out of it. State is the thing components read and write. Storage is a side effect: you hydrate <em>from</em> it once at startup, and you persist <em>to</em> it when state changes. Components never touch storage directly.</p>
<h2>The render-time read that breaks SSR</h2>
<p>Here&#39;s the most common concrete failure, and it&#39;s worth seeing clearly. Someone initializes state straight from storage:</p>
<pre><code class="language-tsx">// looks fine, quietly broken
function useTheme() {
  const [theme, setTheme] = useState(localStorage.getItem(&#39;theme&#39;) ?? &#39;light&#39;);
  // ...
}
</code></pre>
<p>On a purely client-rendered app this mostly works. On anything server-rendered — Next.js, which is what this whole series builds on — it detonates two ways. First, <code>localStorage</code> doesn&#39;t exist on the server, so the render throws. Second, even if you guard it, the server has no idea what&#39;s in the user&#39;s storage, so it renders <code>light</code>, the client hydrates with <code>dark</code>, and React screams about a hydration mismatch — or worse, silently flips the UI after paint. That flash of the wrong theme is the visual signature of &quot;someone read storage during render.&quot;</p>
<p>The fix follows directly from the mirror rule. Render from state with a stable default, then hydrate from storage in an effect, after the first paint:</p>
<pre><code class="language-tsx">function useTheme() {
  const [theme, setThemeState] = useState&lt;&#39;light&#39; | &#39;dark&#39;&gt;(&#39;light&#39;);

  // hydrate once, on the client only
  useEffect(() =&gt; {
    const stored = localStorage.getItem(&#39;theme&#39;);
    if (stored === &#39;light&#39; || stored === &#39;dark&#39;) setThemeState(stored);
  }, []);

  const setTheme = useCallback((next: &#39;light&#39; | &#39;dark&#39;) =&gt; {
    setThemeState(next);
    localStorage.setItem(&#39;theme&#39;, next); // persist as a side effect
  }, []);

  return [theme, setTheme] as const;
}
</code></pre>
<p>Server and client both start at <code>light</code>, so hydration matches. The stored value takes over a tick later. If the theme flash bothers you, that&#39;s a real concern — but you solve it with a tiny inline script in the document head that sets a class before React boots, not by reading storage during React&#39;s render. The two problems stay separate.</p>
<h2>Persist the store, not a hundred components</h2>
<p>Doing this hook-by-hook gets old fast, and it scatters <code>setItem</code> calls everywhere — which is exactly the kind of smeared side effect that makes a codebase hard to reason about. Since this series uses <a href="https://ma-x.im/blog/react-playbook-reatom-state-management">Reatom for state</a>, persistence belongs at the store layer, as one declarative concern, not a hundred manual writes.</p>
<pre><code class="language-ts">import { atom } from &#39;@reatom/core&#39;;
import { withLocalStorage } from &#39;@reatom/persist-web-storage&#39;;

// the atom is the source of truth; localStorage is a declared mirror of it
export const themeAtom = atom&lt;&#39;light&#39; | &#39;dark&#39;&gt;(&#39;light&#39;, &#39;themeAtom&#39;).pipe(
  withLocalStorage(&#39;theme&#39;),
);
</code></pre>
<p>Now the atom is where the value lives and where the UI reads it. The persistence adapter mirrors every change to localStorage and rehydrates on load — including, importantly, listening for changes from <em>other tabs</em>. You wrote zero <code>getItem</code>/<code>setItem</code> calls. The concern lives in one place you can see, test, and change. This is the same principle from the <a href="https://ma-x.im/blog/react-playbook-composition-and-di">composition and DI article</a>: don&#39;t let components reach out and grab infrastructure; hand it to them, or push it to a layer that owns it.</p>
<h2>Two tabs, one storage, a fight</h2>
<p>The surprise that catches people: localStorage is shared across every tab on the same origin. Open your app in two tabs, change the theme in one, and the other tab&#39;s <em>in-memory state</em> has no idea. Its localStorage updated underneath it, but React state didn&#39;t move. The two tabs now disagree, and whichever one writes next wins, clobbering the other.</p>
<p>The browser gives you exactly one tool for this — the <code>storage</code> event, which fires in <em>other</em> tabs (never the one that made the change):</p>
<pre><code class="language-ts">useEffect(() =&gt; {
  const onStorage = (e: StorageEvent) =&gt; {
    if (e.key === &#39;theme&#39; &amp;&amp; (e.newValue === &#39;light&#39; || e.newValue === &#39;dark&#39;)) {
      setThemeState(e.newValue);
    }
  };
  window.addEventListener(&#39;storage&#39;, onStorage);
  return () =&gt; window.removeEventListener(&#39;storage&#39;, onStorage);
}, []);
</code></pre>
<p>A good persistence adapter (like Reatom&#39;s above) already does this for you, which is another reason to centralize it. But you should know the event exists, because the &quot;tabs fight each other&quot; bug is invisible in development — you almost never have two tabs open while coding — and then very visible in production. If tab sync actually matters for your feature, <code>BroadcastChannel</code> is the more deliberate tool; <code>storage</code> is the free one you get for persisting at all.</p>
<h2>Versioning: the crash that ships with v2</h2>
<p>Here&#39;s the failure that only shows up <em>after</em> you succeed. You persist a settings object. Months later you change its shape — rename a field, split one setting into two. You deploy. Every returning user&#39;s browser hydrates the <em>old</em> shape into code that expects the <em>new</em> shape, and something reads <code>settings.notifications.email</code> on an object where <code>notifications</code> is now a string. Crash. On load. For existing users only — which is why your testing missed it.</p>
<p>Persisted data is data you shipped in the past, running against code you shipped today. It needs a version and a migration path, exactly like a database:</p>
<pre><code class="language-ts">type StoredSettings = { version: number; theme: &#39;light&#39; | &#39;dark&#39;; density: &#39;cozy&#39; | &#39;compact&#39; };

function loadSettings(): StoredSettings {
  const raw = localStorage.getItem(&#39;settings&#39;);
  const parsed = raw ? JSON.parse(raw) : null;

  // no data, or an old shape we no longer trust → start clean
  if (!parsed || parsed.version !== 2) {
    return { version: 2, theme: &#39;light&#39;, density: &#39;cozy&#39; };
  }
  return parsed;
}
</code></pre>
<p>Reject-and-reset is the cheapest correct answer for preferences — losing someone&#39;s theme choice is not a tragedy. For anything you can&#39;t throw away, write real migrations keyed on <code>version</code>. Either way, the non-negotiable part is: <strong>never trust the shape of persisted data</strong>. It came from a version of your app you don&#39;t control anymore.</p>
<h2>So what actually goes in there</h2>
<p>Pulling it together, the fit test is simple: store things the client owns and the client alone can restore. Theme and UI preferences. Sidebar collapsed or expanded. A draft of a form the user hasn&#39;t submitted. The last route they were on. Small, client-owned, non-sensitive, and fine to lose in the worst case.</p>
<p>What doesn&#39;t belong: anything sensitive (localStorage is readable by any script on the page — a token there is a token exposed to every XSS), anything large (multi-megabyte blobs want IndexedDB, which is async and built for size), and — the big one — server data. The list of customers, the current user&#39;s profile, the order you just fetched: that data is not yours. It has an owner on the backend, and your copy is a <em>cache</em>, not a persisted preference. Stuffing it into localStorage means you now have two stale copies fighting instead of one.</p>
<p>And that&#39;s the seam into the next article. Everything here was about state the client <em>owns</em> — you&#39;re the source of truth, storage just remembers it. Server state flips that on its head: the source of truth lives somewhere else, the same entity shows up in five different responses, and &quot;persistence&quot; stops being the problem while &quot;which copy is correct&quot; becomes it. That&#39;s data normalization and cache invalidation, and it&#39;s where a lot of otherwise-clean apps quietly turn to mush.</p>
<p>If you&#39;ve got a persistence bug you can&#39;t quite pin down — a flash on load, a tab that resets another, a crash that only hits returning users — tell me the symptom. Nine times out of ten it traces straight back to treating storage as state, and I&#39;d like to hear the tenth.</p>
]]></content:encoded>
      <pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Keeping a UI Kit Updated Across Apps: Versioning, Contracts, and Trust</title>
      <link>https://ma-x.im/blog/react-playbook-ui-kit-versioning</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-ui-kit-versioning</guid>
      <description>A shared UI kit does not fail because the button component is hard to write. It fails when five applications are afraid to upgrade it. Versioning, contract tests, release channels, and migration paths are what turn a component library from a dependency into a product people can trust.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-ui-kit-versioning/cover.png" alt="Keeping a UI Kit Updated Across Apps: Versioning, Contracts, and Trust" /></p>
<p>The hardest part of a UI kit is not writing the components. That&#39;s the trap. You can build a clean <code>&lt;Button&gt;</code>, a flexible <code>&lt;Select&gt;</code>, a beautiful <code>&lt;Modal&gt;</code>, publish them to a package, and feel like the system exists. Then six months later one app is on version 1.4, another is on 2.1, a third has copied half the styles locally, and everyone is scared to upgrade because nobody knows what will break.</p>
<p>At that point the UI kit did not fail as a component library. It failed as a product.</p>
<p>The <a href="https://ma-x.im/blog/react-playbook-changing-shared-components">previous article</a> was about changing a shared component inside one codebase, where you can grep every call site and run a codemod yourself. This is the harder version: several applications consume the kit as a versioned package. You can&#39;t see every usage. You can&#39;t update every app in one pull request. You can&#39;t assume every team upgrades on the same day. So the question changes from &quot;how do I make the component better?&quot; to &quot;how do I make upgrades safe enough that people keep taking them?&quot;</p>
<p>That&#39;s the real work.</p>
<h2>A UI kit is not shared until upgrades are routine</h2>
<p>A lot of teams treat &quot;published package&quot; as the finish line. It isn&#39;t. A package can be shared and still be effectively frozen. If every upgrade requires a migration meeting, manual QA across five apps, and someone brave enough to merge first, the kit is not healthy. It&#39;s a museum.</p>
<p>The signal I care about is boring: can an app upgrade from 2.3.1 to 2.3.2 on a normal Tuesday without drama? Can it take a minor version without reading every diff? Can it delay a major version without blocking everyone else? If the answer is yes, the UI kit is alive. If the answer is no, every improvement you make piles up behind the same wall: adoption risk.</p>
<blockquote>
<p>A design system is only as real as its upgrade path. If people cannot safely move to the next version, the new version mostly exists in your repository.</p>
</blockquote>
<p>This is why &quot;one version everywhere&quot; is a nice goal and a terrible starting assumption. You may want every app on the latest version. You probably should. But the mechanism that gets you there is not hope. It&#39;s compatibility, contracts, release discipline, and migration support.</p>
<h2>Semver is a promise, not a decoration</h2>
<p>Version numbers are not vibes. They are a communication system between the kit and every app that depends on it. If you treat them casually, people stop trusting them.</p>
<p>The contract is simple:</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;@company/ui-kit&quot;,
  &quot;version&quot;: &quot;2.4.0&quot;
}
</code></pre>
<p>A patch release fixes bugs and should be safe. A minor release adds capabilities without breaking existing usage. A major release may require changes, and the migration path must be explicit. That&#39;s semver on paper. In practice, the hard part is being honest about what counts as breaking.</p>
<p>Changing a prop type is obviously breaking. Removing a component is breaking. But so is changing keyboard behavior in a menu. So is changing focus trapping in a modal. So is changing the DOM structure if consumers style nested elements, even if you wish they didn&#39;t. So is renaming a CSS variable that apps rely on. UI libraries have more contracts than TypeScript can see.</p>
<p>That means the rule has to be conservative: if a reasonable consumer could notice the change in code, behavior, accessibility, or visual output, treat it as a contract change. Maybe it&#39;s still worth doing. Just don&#39;t smuggle it into a patch release and call the upgrade safe.</p>
<h2>Public API must be smaller than the folder tree</h2>
<p>This connects directly back to the <a href="https://ma-x.im/blog/react-playbook-barrel-files">barrel files article</a>. A UI kit needs a public API, but that API has to be intentional. If apps can import from anywhere inside the package, every internal file becomes a promise you didn&#39;t mean to make.</p>
<p>Bad shape:</p>
<pre><code class="language-tsx">import { Button } from &#39;@company/ui-kit/src/components/button/button&#39;;
import { buttonRecipe } from &#39;@company/ui-kit/src/components/button/styles&#39;;
import { useModalMachine } from &#39;@company/ui-kit/src/internal/modal-machine&#39;;
</code></pre>
<p>Now your internal structure is part of the external contract. You can&#39;t reorganize files. You can&#39;t swap the modal implementation. You can&#39;t rename a style recipe. The consuming apps have turned your source tree into their API.</p>
<p>Good shape:</p>
<pre><code class="language-tsx">import { Button, Dialog, Select } from &#39;@company/ui-kit&#39;;
import { useCombobox } from &#39;@company/ui-kit/headless&#39;;
import { tokens } from &#39;@company/ui-kit/tokens&#39;;
</code></pre>
<p>Three doors. Components, headless behavior, tokens. Everything else is private. If someone needs a new door, you add it intentionally. You don&#39;t let them crawl through a window because the import path happened to work.</p>
<p>In package terms, that means using <code>exports</code> to make the boundary real:</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;@company/ui-kit&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: &quot;./dist/index.js&quot;,
    &quot;./headless&quot;: &quot;./dist/headless/index.js&quot;,
    &quot;./tokens&quot;: &quot;./dist/tokens/index.js&quot;
  }
}
</code></pre>
<p>This is a small detail with a large consequence. You can only version what you control. A tight public API gives you something finite to protect.</p>
<h2>Contract tests are how apps talk back</h2>
<p>The uncomfortable part of maintaining a UI kit across apps is that unit tests inside the kit are not enough. They tell you your component works according to your assumptions. They do not tell you whether the apps use it in ways you forgot to protect.</p>
<p>The answer is not to pull every application into your test suite. That turns the kit into a build-system hostage. The better answer is a thin layer of contract tests: small fixtures that represent the promises the kit makes to consumers.</p>
<pre><code class="language-tsx">// contracts/dialog.contract.tsx
import { render, screen } from &#39;@testing-library/react&#39;;
import userEvent from &#39;@testing-library/user-event&#39;;
import { Dialog } from &#39;@company/ui-kit&#39;;

it(&#39;keeps focus inside the dialog until it closes&#39;, async () =&gt; {
  render(
    &lt;Dialog open title=&quot;Delete customer&quot;&gt;
      &lt;button&gt;Cancel&lt;/button&gt;
      &lt;button&gt;Delete&lt;/button&gt;
    &lt;/Dialog&gt;,
  );

  await userEvent.tab();
  expect(screen.getByRole(&#39;button&#39;, { name: &#39;Cancel&#39; })).toHaveFocus();

  await userEvent.tab();
  expect(screen.getByRole(&#39;button&#39;, { name: &#39;Delete&#39; })).toHaveFocus();
});
</code></pre>
<p>That test is not checking implementation. It is checking a consumer-visible promise: focus stays inside the dialog. If you rewrite the internals from one focus-trap library to another, the contract should still pass. If it doesn&#39;t, you didn&#39;t make a refactor. You changed behavior.</p>
<p>You need contracts in four layers:</p>
<p><strong>Type contracts.</strong> A small TypeScript test project that imports the public API the way apps do. If exported types break, the kit fails before publishing.</p>
<p><strong>Behavior contracts.</strong> Testing Library tests for keyboard behavior, focus, ARIA, controlled/uncontrolled state, and callback order.</p>
<p><strong>Visual contracts.</strong> Storybook or fixture pages with visual regression on the stable states: default, hover, disabled, loading, error, long text, right-to-left if you support it.</p>
<p><strong>Token contracts.</strong> Tests that verify CSS variables and design tokens still exist with expected names. Tokens are API. Treat them that way.</p>
<p>Not every component needs all four layers. A plain <code>Badge</code> probably needs visual and token coverage. A <code>Combobox</code> needs behavior and accessibility coverage badly. The point is to test the promises consumers depend on, not every internal branch.</p>
<h2>Release channels keep risk from spreading</h2>
<p>If every release goes straight to <code>latest</code>, your first consumer becomes the canary whether they agreed to it or not. That&#39;s not fair to them, and it trains people to wait. A better release flow gives the kit a place to be wrong before the stable version moves.</p>
<p>For example:</p>
<pre><code class="language-bash"># experimental builds for early adopters
npm publish --tag next

# stable builds after contracts and canary apps pass
npm publish --tag latest
</code></pre>
<p>The <code>next</code> tag is where breaking work, risky rewrites, and big visual changes can be tested by one or two willing apps. <code>latest</code> is the calm channel. It should feel boring. If a team installs <code>latest</code> and gets surprise churn, they will stop trusting the kit. And when trust goes, adoption goes with it.</p>
<p>This does not require a huge platform. Start with one canary app — ideally the app that uses the kit heavily and has the fastest feedback loop. Upgrade it first. Run its smoke tests. Look at the core screens. Fix the kit before asking everyone else to move. The canary app is not there to prove the kit is perfect. It is there to catch the obvious problems while the blast radius is still small.</p>
<h2>Migration support is part of the feature</h2>
<p>A breaking release without a migration path is not a release. It&#39;s homework assigned to every consuming app.</p>
<p>If you remove <code>Button color</code> in favor of <code>Button intent</code>, the release should include more than a changelog sentence. It should include the same tools from the previous article, but packaged for consumers who live outside your repo:</p>
<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;migrate:ui-kit&quot;: &quot;npx @company/ui-kit-codemods button-color-to-intent src&quot;
  }
}
</code></pre>
<p>The codemod can ship as a small companion package. The migration guide should show old usage, new usage, edge cases, and what the script can and cannot fix. The deprecation should exist for at least one minor cycle before the major removal, unless the old behavior is actively dangerous. And the major release should be boring precisely because the hard work already happened while the old API still existed.</p>
<p>Honestly, this is where many UI kits lose people. The maintainers ship a clean new API and assume consumers will be grateful. Consumers look at their backlog, see forty files to touch, and pin the old version for another year. The new API may be better, but the upgrade experience is worse, so nobody moves.</p>
<h2>Documentation has to be versioned too</h2>
<p>One small but painful failure mode: the documentation shows the latest version, but half the apps are still on an older one. A developer opens the docs, copies an example, and the prop doesn&#39;t exist. Now the UI kit feels broken even though the app is just behind.</p>
<p>If multiple apps can be on different versions, the docs need to acknowledge that reality. At minimum, every docs page should clearly say which version it describes. Better: keep versioned docs for major versions, or at least preserve migration pages long after the release goes out. A migration guide is most useful six months after you wrote it, when the last app finally upgrades.</p>
<p>This also applies to design tokens and screenshots. If your docs render from the latest package but an app is on v2, the visual examples can lie. Sometimes that&#39;s acceptable. Sometimes it creates bugs. Be explicit either way.</p>
<h2>The rule I use: optimize for trust</h2>
<p>There are many tactical choices here — monorepo or separate repo, Changesets or semantic-release, Storybook or Ladle, visual regression provider A or B. Those choices matter, but they&#39;re not the center. Trust is the center.</p>
<p>Trust means a patch release doesn&#39;t redesign the app. Trust means a minor release doesn&#39;t remove a prop. Trust means a major release comes with a migration path. Trust means public imports stay public and private imports are actually blocked. Trust means if a component&#39;s behavior changes, there is a test that calls it a behavior change. Trust means teams believe the kit maintainers are trying to make upgrades smaller, not throw work over the wall.</p>
<p>Once that trust exists, you can improve the UI kit continuously. Without it, every improvement becomes suspicious. People fork. They pin. They copy styles locally. They build one-off replacements because waiting for the shared kit feels slower than escaping it. That is how a design system dies: not from one bad component, but from a long series of upgrades people chose not to take.</p>
<p>This closes the component-patterns run: compound components for structure, headless components for behavior, incremental migration inside one repo, and now versioned upgrades across many apps. Next I want to move into state and data at scale, starting with the deceptively simple persistence question: what belongs in local storage, what belongs in global state, and what breaks when you pretend they&#39;re the same thing.</p>
<p>If your UI kit is stuck on an old version somewhere, I&#39;m curious what the real blocker is. Is it semver trust, missing migration tools, visual regression fear, or just nobody owning the upgrade? Send me the ugly reason. That&#39;s usually where the architecture lives.</p>
]]></content:encoded>
      <pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Changing a Component Everyone Uses: Deprecation, Codemods, and No Big-Bang Rewrites</title>
      <link>https://ma-x.im/blog/react-playbook-changing-shared-components</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-changing-shared-components</guid>
      <description>Writing the new version of a shared component is the easy 10%. The other 90% is the hundred call sites that already depend on the old one — and the fear of touching them. Here&apos;s how I change a component everyone uses without a month-long rewrite that breaks the day it lands.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-changing-shared-components/cover.png" alt="Changing a Component Everyone Uses: Deprecation, Codemods, and No Big-Bang Rewrites" /></p>
<p>At the end of the <a href="https://ma-x.im/blog/react-playbook-headless-components">last article</a> I told you to go find the one component everyone reimplements slightly differently. So let&#39;s say you found it. You know exactly how it should work now. There&#39;s just one problem: a hundred files already import the old one, and half of them belong to teams you&#39;ve never met.</p>
<p>This is the part of building shared components nobody puts on the roadmap. Writing the new <code>&lt;Button&gt;</code> is the easy 10%. The other 90% is every place that already calls the old <code>&lt;Button&gt;</code>, and the very specific fear that comes with editing code you don&#39;t own to fix a component that, technically, still works. That fear is why so many &quot;we&#39;ll clean this up later&quot; components never get cleaned up. The change isn&#39;t hard. Changing it <em>everywhere, safely, without a flag day</em> is hard.</p>
<p>So this article isn&#39;t about how to design a better component. It&#39;s about how to move an existing one forward when it already has dozens of dependents — deprecation paths, codemods, and the discipline that lets you touch a hundred call sites without holding your breath.</p>
<h2>The big-bang rewrite is the trap</h2>
<p>Here&#39;s the instinct I have to talk teams out of constantly. You look at the old component, you see everything wrong with it, and you decide to rewrite it properly on a branch. Two weeks later that branch has touched sixty files, it&#39;s three hundred commits behind main, every rebase is a knife fight, and the only way to land it is a single terrifying merge that changes everything at once. If anything breaks — and something always breaks — you can&#39;t tell which of the sixty changes did it.</p>
<p>That&#39;s a flag day: a moment where the whole codebase has to flip from old to new simultaneously. Flag days feel decisive and they are almost always a mistake. The problem isn&#39;t your ability to write the new component. The problem is that you tried to <em>break</em> and <em>rebuild</em> in the same step, so there&#39;s never a moment where the system is in a known-good state. You&#39;re either fully migrated or fully broken, with nothing in between.</p>
<p>The fix is to refuse the whole framing. You don&#39;t rewrite. You <em>evolve</em>.</p>
<blockquote>
<p>You never break and rebuild in one step. You add the new path, move callers over one at a time, then delete the old path. Three boring, reversible steps instead of one heroic irreversible one.</p>
</blockquote>
<p>That shape has a few names — parallel change, expand/contract, the strangler pattern — but at the component level it&#39;s just this: <strong>expand</strong> the component so old and new both work, <strong>migrate</strong> the call sites gradually, then <strong>contract</strong> by removing the old way once nothing uses it. Every intermediate state ships. Every step is revertible on its own. Nothing has a flag day.</p>
<h2>Step one: make the change additive</h2>
<p>The whole strategy depends on old and new coexisting for a while. So the first move is to add the new API <em>next to</em> the old one instead of replacing it. Say I&#39;m moving a <code>&lt;Button&gt;</code> from a raw <code>color</code> prop to a semantic <code>intent</code> prop — because <code>color=&quot;green&quot;</code> tells you nothing about <em>why</em> it&#39;s green, and the theme can&#39;t remap it.</p>
<p>The tempting move is to rename <code>color</code> to <code>intent</code> and let TypeScript light up a hundred errors. Don&#39;t. Add <code>intent</code>, keep <code>color</code>, and translate internally:</p>
<pre><code class="language-tsx">type ButtonProps = {
  /** @deprecated Use `intent` instead. `color` is removed in v3. */
  color?: &#39;green&#39; | &#39;red&#39; | &#39;gray&#39;;
  intent?: &#39;success&#39; | &#39;danger&#39; | &#39;neutral&#39;;
  children: React.ReactNode;
};

const colorToIntent = {
  green: &#39;success&#39;,
  red: &#39;danger&#39;,
  gray: &#39;neutral&#39;,
} as const;

export function Button({ color, intent, children }: ButtonProps) {
  // new prop wins; old prop still works via a mapping
  const resolved = intent ?? (color ? colorToIntent[color] : &#39;neutral&#39;);

  if (process.env.NODE_ENV !== &#39;production&#39; &amp;&amp; color &amp;&amp; !intent) {
    console.warn(
      `Button: the \`color\` prop is deprecated. Use intent=&quot;${colorToIntent[color]}&quot; instead.`,
    );
  }

  return &lt;button className={intentClasses[resolved]}&gt;{children}&lt;/button&gt;;
}
</code></pre>
<p>Nobody&#39;s build breaks. Every existing <code>color=&quot;green&quot;</code> keeps rendering exactly as before. New code uses <code>intent</code>. And critically, the component itself now <em>knows</em> the mapping from old to new — which is going to matter in about thirty seconds when we automate the migration.</p>
<blockquote>
<p>A backward-compatible change buys you time, and time is the one thing a big-bang rewrite refuses to give you. Coexistence isn&#39;t a compromise. It&#39;s the whole plan.</p>
</blockquote>
<h2>Step two: deprecation is a message, not a delete</h2>
<p>Marking something <code>@deprecated</code> is not the same as deleting it, and treating them as the same thing is why deprecations get ignored. A deprecation is a <em>communication channel</em>. Its entire job is to tell the next person who touches this code what to do instead — clearly, at the exact moment they&#39;d otherwise reach for the old thing.</p>
<p>That JSDoc <code>@deprecated</code> tag isn&#39;t decorative. Editors strike through the prop and surface the message on hover, so a developer typing <code>color=</code> sees the replacement before they even hit save. The runtime warning catches the code that&#39;s already written. And if you want teeth, an ESLint rule turns &quot;please stop&quot; into &quot;the build is yellow until you stop&quot;:</p>
<pre><code class="language-js">// eslint config — nudge, don&#39;t block yet
&#39;no-restricted-syntax&#39;: [
  &#39;warn&#39;,
  {
    selector: &#39;JSXAttribute[name.name=&quot;color&quot;]&#39;,
    message: &#39;Button: the color prop is deprecated — use intent. Run the codemod: npx tsx scripts/button-color-to-intent.ts&#39;,
  },
],
</code></pre>
<p>The one rule I hold to: <strong>a deprecation must always name its replacement.</strong> &quot;This is deprecated&quot; with no path forward isn&#39;t a deprecation, it&#39;s nagging. Every warning I write ends with the exact thing to type instead, and ideally the command that&#39;ll do it for you. Which brings us to that command.</p>
<h2>Step three: codemods move the boring changes for you</h2>
<p>Here&#39;s where the hundred call sites stop being scary. If the change is mechanical — <code>color=&quot;green&quot;</code> becomes <code>intent=&quot;success&quot;</code>, every time, no judgment required — then asking humans to do it a hundred times is both slow and a source of typos. A codemod is a script that rewrites source code by editing its syntax tree, and this is exactly what they&#39;re for.</p>
<pre><code class="language-ts">// scripts/button-color-to-intent.ts — jscodeshift transform
import type { API, FileInfo } from &#39;jscodeshift&#39;;

const map: Record&lt;string, string&gt; = { green: &#39;success&#39;, red: &#39;danger&#39;, gray: &#39;neutral&#39; };

export default function transform(file: FileInfo, api: API) {
  const j = api.jscodeshift;
  const root = j(file.source);

  root
    .find(j.JSXAttribute, { name: { name: &#39;color&#39; } })
    // only touch color props on &lt;Button&gt;
    .filter((path) =&gt; {
      const el = path.parent.node;
      return el.name?.type === &#39;JSXIdentifier&#39; &amp;&amp; el.name.name === &#39;Button&#39;;
    })
    .forEach((path) =&gt; {
      const value = path.node.value;
      if (value?.type === &#39;Literal&#39; &amp;&amp; typeof value.value === &#39;string&#39; &amp;&amp; map[value.value]) {
        path.node.name.name = &#39;intent&#39;;
        value.value = map[value.value];
      }
    });

  return root.toSource();
}
</code></pre>
<p>Run it across the repo and every <code>&lt;Button color=&quot;green&quot;&gt;</code> becomes <code>&lt;Button intent=&quot;success&quot;&gt;</code> in one commit — a commit a reviewer can actually read, because the diff is uniform and mechanical. That&#39;s the real payoff: not just speed, but a <em>reviewable</em> migration instead of a hundred hand-edited files where any one of them could hide a mistake.</p>
<p>Codemods aren&#39;t always the answer, though, and I&#39;ve watched people burn a day writing one for a change they could&#39;ve done by hand in ten minutes. My test is two questions. Are there enough call sites that doing it manually is genuinely painful — dozens, not five? And is the transform <em>mechanical</em>, with no case where a human has to decide what the right replacement is? If either answer is no, skip the codemod. When the change needs judgment — &quot;replace this prop, but only where the old behavior was actually intended&quot; — a script can&#39;t make that call, and pretending it can just moves the bugs somewhere quieter.</p>
<h2>When it can&#39;t be additive: version it side by side</h2>
<p>Sometimes the new component isn&#39;t a tweaked version of the old one — it&#39;s a different thing that happens to share a name. A <code>&lt;DataTable&gt;</code> that moves from a props-driven column config to a headless row model isn&#39;t a new prop; it&#39;s a new mental model. You can&#39;t map the old API onto the new one, so coexistence-through-props doesn&#39;t work.</p>
<p>That&#39;s when you version at the module level instead of the prop level. Ship the new one alongside the old under its own name, and let call sites move when they&#39;re ready:</p>
<pre><code class="language-tsx">// old one keeps living, untouched, so nothing breaks
export { DataTable } from &#39;./data-table-legacy&#39;;
// new one ships next to it under a clear name
export { DataTableV2 } from &#39;./data-table-v2&#39;;
</code></pre>
<p>It&#39;s the same expand/migrate/contract shape, just one level up. Both exist. Features migrate to <code>DataTableV2</code> on their own schedule, each migration a small reviewable change instead of a codebase-wide flip. When the last import of the legacy one is gone, you delete it. The name <code>V2</code> is ugly on purpose — it&#39;s a temporary state that&#39;s supposed to disappear, and its ugliness is a standing reminder that the migration isn&#39;t finished. If <code>DataTableV2</code> is still around in a year, that&#39;s a signal, not a feature.</p>
<h2>Step four: contract — actually delete the old thing</h2>
<p>This is the step everyone skips, and skipping it quietly undoes all the discipline that came before. Expand and migrate without contract just leaves you with <em>two</em> ways to do everything, forever, plus a warning nobody reads anymore. The deprecation was a promise to remove the old path. Contract is keeping it.</p>
<p>The good news is that after the additive change, the deprecation, and the codemod, contract is almost anticlimactic. You check that usage has actually hit zero — and you gate it, don&#39;t eyeball it:</p>
<pre><code class="language-bash"># fails CI if a single old-style usage survives
grep -rn &#39;color=&#39; src/ --include=&#39;*.tsx&#39; &amp;&amp; echo &quot;still used — not safe to remove&quot; &amp;&amp; exit 1
</code></pre>
<p>When that returns nothing, you delete the <code>color</code> prop, the mapping, the warning, and the ESLint rule in one small commit. The component gets <em>simpler</em> — which is the tell that you did the whole thing right. A migration that ends with more code than it started with wasn&#39;t a migration, it was an accretion.</p>
<h2>What this really changes</h2>
<p>The thing I want to leave you with isn&#39;t the codemod or the deprecation tag — it&#39;s the reframing. Once you stop seeing &quot;change a shared component&quot; as one scary event and start seeing it as expand, migrate, contract, the fear mostly evaporates. Not because the work got smaller, but because every step is now individually safe and individually revertible. You&#39;re never one merge away from a broken build. You&#39;re always in a known-good state that happens to support two ways of doing something for a while.</p>
<p>And honestly, the willingness to make this move is a large part of what separates a component library people trust from one they&#39;re afraid of. A kit whose owners can evolve it safely keeps getting better. A kit whose owners are scared to touch it ossifies — every rough edge becomes permanent because fixing it feels too risky. The techniques here are what buy you the confidence to keep improving the thing everyone depends on.</p>
<p>Which raises the harder version of the same problem, and where I want to go next: it&#39;s one thing to evolve a component inside <em>one</em> repo where you can grep every call site. It&#39;s another to keep a shared UI kit in sync across <em>several</em> applications that each pull it in as a versioned package — where you can&#39;t see the call sites, can&#39;t run a codemod across them, and &quot;just delete the old prop&quot; can break an app you don&#39;t even have checked out. That&#39;s the next article.</p>
<p>If you&#39;ve got a shared component you&#39;ve been too scared to change, I&#39;d genuinely like to hear what&#39;s holding it back — is it the call-site count, a missing safety net, or just that nobody&#39;s allowed to own it? Reach out and tell me which.</p>
]]></content:encoded>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Headless Components: Give Away the Behavior, Keep None of the Markup</title>
      <link>https://ma-x.im/blog/react-playbook-headless-components</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-headless-components</guid>
      <description>The most reusable UI code I write renders nothing at all. Headless components, render props, and hooks-as-API are three names for one idea: separate what a component does from how it looks — and hand over the behavior without a single opinion about the markup.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-headless-components/cover.png" alt="Headless Components: Give Away the Behavior, Keep None of the Markup" /></p>
<p>Here&#39;s a thing that took me too long to internalize: the most reusable UI code I&#39;ve ever written renders nothing. No <code>&lt;div&gt;</code>, no class name, no markup at all. It just computes behavior and hands it to whoever called it. That sounds backwards — a UI component that produces no UI? — but it&#39;s the idea behind every component library that survived contact with more than one design system.</p>
<p><a href="https://ma-x.im/blog/react-playbook-compound-components">Compound components</a> took us most of the way there: let the caller control structure while the component owns the coordination. But a compound component still ships markup. <code>&lt;Select.Option&gt;</code> renders a <code>div</code>, picks a class, decides on padding. The moment two products with different designs both want that select behavior, that markup becomes a fight. Headless components are the answer to &quot;what if the component gave up the markup entirely and kept only the behavior.&quot;</p>
<h2>The coupling nobody notices until it hurts</h2>
<p>Think about what a <code>&lt;Combobox&gt;</code> actually does. It manages an open/closed state. It tracks the highlighted option. It handles arrow keys, Enter, Escape, typeahead. It wires up the ARIA attributes so screen readers announce it correctly. It filters options as you type. That&#39;s a <em>lot</em> of genuinely hard logic — the kind with edge cases you&#39;ll get wrong the first three times.</p>
<p>And then, almost as an afterthought, it renders some divs with some classes.</p>
<p>The hard part — the behavior — is completely universal. Every combobox everywhere needs the same keyboard handling and the same accessibility wiring. The easy part — the markup and styles — is completely specific. Your combobox looks nothing like mine. Yet a normal component welds those two together, so when I want your combobox&#39;s excellent keyboard handling but my design, I can&#39;t have it. I either fork your styles or reimplement your logic. Both are bad, and I&#39;ve done both.</p>
<blockquote>
<p>The behavior is universal. The markup is personal. A normal component fuses them and forces you to take both or neither.</p>
</blockquote>
<p>Headless components unfuse them. They keep all the hard, universal behavior and give away <em>zero</em> markup, letting you supply the personal part yourself.</p>
<h2>Three shapes of the same idea</h2>
<p>&quot;Headless&quot; isn&#39;t one API. It&#39;s a family of three techniques that all separate behavior from rendering. They&#39;re worth knowing as a set, because you&#39;ll pick between them by feel.</p>
<p><strong>Render props / children-as-a-function.</strong> The component computes state and calls <em>your</em> function to render. It decides nothing about looks.</p>
<pre><code class="language-tsx">&lt;Toggle&gt;
  {({ on, toggle }) =&gt; (
    &lt;button onClick={toggle} className={on ? &#39;bg-[#22c55e]&#39; : &#39;bg-gray-300&#39;}&gt;
      {on ? &#39;On&#39; : &#39;Off&#39;}
    &lt;/button&gt;
  )}
&lt;/Toggle&gt;
</code></pre>
<p><code>Toggle</code> owns the on/off logic. You own every pixel. It renders literally nothing except by calling your function.</p>
<p><strong>Hooks-as-API.</strong> The same idea, but the behavior is a hook instead of a component. This is the modern default, and honestly the cleanest — no wrapper component in the tree at all, just logic you call and props you spread.</p>
<pre><code class="language-tsx">function ColorSwatch() {
  const toggle = useToggle();
  return (
    &lt;button
      onClick={toggle.toggle}
      aria-pressed={toggle.on}
      className={toggle.on ? &#39;bg-[#22c55e]&#39; : &#39;bg-gray-300&#39;}
    &gt;
      {toggle.on ? &#39;On&#39; : &#39;Off&#39;}
    &lt;/button&gt;
  );
}
</code></pre>
<p><strong>Prop getters.</strong> For complex widgets, the hook hands you functions that return the props you need to spread onto your elements — including all the fiddly ARIA and event wiring you&#39;d never remember to add yourself.</p>
<pre><code class="language-tsx">function Search() {
  const { getInputProps, getMenuProps, getItemProps, items, isOpen } = useCombobox({ items: options });
  return (
    &lt;div&gt;
      &lt;input {...getInputProps()} /&gt;
      &lt;ul {...getMenuProps()}&gt;
        {isOpen &amp;&amp; items.map((item, i) =&gt; (
          &lt;li key={item.id} {...getItemProps({ item, index: i })}&gt;{item.label}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>You wrote the <code>input</code>, the <code>ul</code>, the <code>li</code> — your elements, your styles. But <code>getInputProps()</code> spread the ARIA roles, the keyboard handlers, the focus management onto them. You got the world-class behavior and kept total control of the markup. That&#39;s the whole promise in one code block, and it&#39;s why libraries like Downshift, React Aria, TanStack Table, and the Radix primitives are built exactly this way.</p>
<h2>Why this is dependency injection wearing a costume</h2>
<p>If this feels familiar, it should. Back in the <a href="https://ma-x.im/blog/react-playbook-composition-and-di">composition and DI article</a> I argued that a component should be handed its dependencies instead of reaching out to grab them. Headless components are that same principle turned inside out: instead of injecting <em>data or services</em> into a component, you&#39;re injecting the <em>rendering</em> into a behavior. The behavior doesn&#39;t reach out and decide how things look — you hand the looks in.</p>
<p>That&#39;s why the same benefits show up. A headless <code>useCombobox</code> is trivially testable, because you can drive its logic without rendering any particular UI. It&#39;s endlessly reusable, because it makes no assumption about markup. And it lets you change your mind about design without touching the logic, because the logic never knew what it looked like in the first place. Separation of concerns stops being a slogan and becomes a physical fact: the concern that&#39;s hard and universal lives in one place, the concern that&#39;s easy and specific lives in yours, and they meet through a thin, explicit interface.</p>
<h2>The cost, honestly</h2>
<p>I&#39;m not going to pretend this is free, because the failure mode is real and I&#39;ve watched teams walk into it. Headless components move work <em>to the caller</em>. When you use a styled <code>&lt;Button&gt;</code>, you get a button and you&#39;re done. When you use a headless toggle, you have to render the button, style it, and handle the states yourself. Multiply that across every component and a fully headless design system can feel like it gives you a pile of hooks and a homework assignment.</p>
<p>So the real-world answer is usually a layer cake, not a religion. Headless primitives at the bottom — the hard behavior, unstyled. A <em>styled</em> layer on top of them — your design system&#39;s actual <code>&lt;Button&gt;</code>, <code>&lt;Combobox&gt;</code>, <code>&lt;Select&gt;</code>, built once on the headless base and used everywhere. Most of your app imports the styled layer and never thinks about the headless one. The headless layer is there for the 10% of cases where the styled component doesn&#39;t fit and you need to drop down and rebuild the look while keeping the behavior. You get convenience by default and an escape hatch when you need it — instead of choosing between a rigid component library and reinventing keyboard navigation.</p>
<p>That layering is also the answer to the <a href="https://ma-x.im/blog/building-design-systems-that-scale">UI-kit-across-many-apps problem</a> I&#39;ve written about before: the behavior is shared once at the headless layer, and each app skins it without forking the logic. Same accessibility, same keyboard handling, different paint.</p>
<h2>What I actually reach for</h2>
<p>My rule of thumb is short. If a component&#39;s <em>behavior</em> is hard and its <em>look</em> varies — combobox, date picker, dropdown menu, data table, anything with keyboard and ARIA — I want it headless underneath, styled on top. If a component&#39;s look is fixed and its behavior is trivial, a plain styled component is right and headless is overkill. Most of a codebase is the second kind. But the handful of components that are the first kind are exactly the ones that cause the most pain when they&#39;re not headless, because they&#39;re the ones everyone needs slightly differently.</p>
<p>The mindset shift, though, is the real takeaway — the same one compound components started. You stop thinking &quot;I&#39;m building a combobox&quot; and start thinking &quot;I&#39;m building combobox <em>behavior</em>, and the combobox is just one thing someone might render with it.&quot; Once you see UI logic as separable from UI markup, you can&#39;t unsee it, and you start noticing how much supposedly-reusable code isn&#39;t reusable at all — not because the logic is wrong, but because it&#39;s handcuffed to one specific set of divs.</p>
<p>That wraps the two most important component patterns — compose the structure, then hand off the behavior. Next in this run I want to get at the unglamorous half of building shared components: how you <em>change</em> one that dozens of places already depend on, without a giant risky rewrite — deprecation paths, codemods, and updating call sites without fear. If you&#39;ve got a component library, go find the one component everyone reimplements slightly differently. That&#39;s almost always a behavior that should have been headless — tell me which one it is in yours.</p>
]]></content:encoded>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Compound Components: Designing an API, Not Just a Component</title>
      <link>https://ma-x.im/blog/react-playbook-compound-components</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-compound-components</guid>
      <description>The moment a component grows past a handful of props, you&apos;re not writing a component anymore — you&apos;re designing an API. Compound components are how you keep that API flexible without drowning it in configuration props.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-compound-components/cover.png" alt="Compound Components: Designing an API, Not Just a Component" /></p>
<p>You build a <code>&lt;Select&gt;</code>. It starts clean — options in, value out. Then the requests arrive. Someone needs a divider between groups. Someone needs an icon next to certain options. Someone needs a sticky &quot;create new&quot; row at the bottom. Someone needs a disabled option with a tooltip explaining why. Each request adds a prop: <code>groups</code>, <code>renderIcon</code>, <code>footerSlot</code>, <code>disabledReason</code>. Six months later your <code>&lt;Select&gt;</code> takes twenty-three props, half of them undocumented, and every new requirement means editing the component and praying you didn&#39;t break the other twenty-two use-cases.</p>
<p>This is the prop-explosion death spiral, and it happens to every successful shared component. The problem isn&#39;t that you added props badly. It&#39;s that props were the wrong tool for the job somewhere around request number three. What you actually needed was to stop configuring a component and start <em>composing</em> one. That&#39;s what compound components are.</p>
<p>The last few articles have been about boundaries and where code lives. This one starts a run on the <em>patterns</em> you build inside those boundaries — the techniques that decide whether the components you write get reused or quietly rewritten. Compound components are the first one because they&#39;re the most common thing people get wrong.</p>
<h2>The wall you hit with props</h2>
<p>Props are perfect right up until the caller needs control over <em>structure</em>. A boolean, a string, a callback — props handle those beautifully. But the moment the caller wants to decide <em>what goes where</em> — the order of things, what sits between them, what wraps them — props turn into a configuration language you&#39;re now forced to invent and maintain.</p>
<pre><code class="language-tsx">// the prop-explosion version — every layout decision is a prop
&lt;Select
  options={options}
  value={value}
  onChange={setValue}
  renderOption={(o) =&gt; &lt;Icon name={o.icon} /&gt;}
  groupBy=&quot;category&quot;
  groupHeaderRenderer={(g) =&gt; &lt;strong&gt;{g}&lt;/strong&gt;}
  footer={&lt;CreateNewRow /&gt;}
  showDividers
  dividerAfter={[2, 5]}
/&gt;
</code></pre>
<p>Look at that <code>dividerAfter={[2, 5]}</code>. That&#39;s the smell. You&#39;re passing array indices to describe where a line should appear, because the component owns the structure and the caller has to negotiate with it through props. Every new layout wish is a new prop, a new branch inside the component, and a new way for the component to break. The component knows too much about every caller, and every caller knows too much about the component&#39;s internal prop vocabulary.</p>
<blockquote>
<p>When callers start passing you layout instructions as props, they&#39;re asking for composition. Give it to them.</p>
</blockquote>
<h2>Compound components invert the control</h2>
<p>A compound component flips the relationship. Instead of one component that takes a pile of props describing structure, you expose a <em>set</em> of components that the caller arranges themselves. The classic shape everyone knows is the native <code>&lt;select&gt;</code>:</p>
<pre><code class="language-html">&lt;select&gt;
  &lt;option&gt;One&lt;/option&gt;
  &lt;optgroup label=&quot;More&quot;&gt;
    &lt;option&gt;Two&lt;/option&gt;
  &lt;/optgroup&gt;
&lt;/select&gt;
</code></pre>
<p>You don&#39;t pass <code>&lt;select&gt;</code> a <code>groups</code> prop. You <em>compose</em> <code>&lt;option&gt;</code> and <code>&lt;optgroup&gt;</code> inside it. The React version of that idea looks like this:</p>
<pre><code class="language-tsx">&lt;Select value={value} onChange={setValue}&gt;
  &lt;Select.Option value=&quot;one&quot;&gt;One&lt;/Select.Option&gt;
  &lt;Select.Divider /&gt;
  &lt;Select.Group label=&quot;More&quot;&gt;
    &lt;Select.Option value=&quot;two&quot; icon={&lt;StarIcon /&gt;}&gt;Two&lt;/Select.Option&gt;
  &lt;/Select.Group&gt;
  &lt;Select.Footer&gt;&lt;CreateNewRow /&gt;&lt;/Select.Footer&gt;
&lt;/Select&gt;
</code></pre>
<p>Every one of those twenty-three props just became a component the caller places wherever they want. Need a divider after the second item? Put a <code>&lt;Select.Divider /&gt;</code> there. Need an icon? Pass it to that one <code>&lt;Select.Option&gt;</code>. The component stopped owning the structure, and the caller took it back — which is exactly what they were trying to do with all those props. You didn&#39;t add flexibility by adding configuration. You added it by <em>removing</em> configuration and letting composition do the work.</p>
<h2>The part that makes it actually work: shared state</h2>
<p>Here&#39;s the question that trips people up: if <code>&lt;Select.Option&gt;</code> is a separate component sitting who-knows-how-deep inside the tree, how does clicking it tell <code>&lt;Select&gt;</code> about the new value? You can&#39;t pass props down — you don&#39;t own the JSX in between. The answer is React Context, and this is the one piece of machinery that turns a pile of dotted components into a real compound component.</p>
<pre><code class="language-tsx">const SelectContext = createContext&lt;{
  value: string;
  onChange: (v: string) =&gt; void;
} | null&gt;(null);

function Select({ value, onChange, children }: SelectProps) {
  return (
    &lt;SelectContext.Provider value={{ value, onChange }}&gt;
      &lt;div role=&quot;listbox&quot;&gt;{children}&lt;/div&gt;
    &lt;/SelectContext.Provider&gt;
  );
}

function Option({ value, icon, children }: OptionProps) {
  const ctx = useContext(SelectContext);
  if (!ctx) throw new Error(&#39;&lt;Select.Option&gt; must be used inside &lt;Select&gt;&#39;);
  const selected = ctx.value === value;
  return (
    &lt;div
      role=&quot;option&quot;
      aria-selected={selected}
      onClick={() =&gt; ctx.onChange(value)}
      className={selected ? &#39;bg-[#22c55e]/10&#39; : &#39;&#39;}
    &gt;
      {icon}
      {children}
    &lt;/div&gt;
  );
}

// attach the children as properties — this is what enables the dot syntax
Select.Option = Option;
Select.Divider = () =&gt; &lt;div role=&quot;separator&quot; className=&quot;my-1 border-t border-gray-200&quot; /&gt;;
Select.Group = Group;
Select.Footer = Footer;
</code></pre>
<p>The parent puts the shared state into context. Every child reads from context, so it doesn&#39;t matter how deeply it&#39;s nested or what the caller wraps it in — an <code>&lt;Option&gt;</code> three divs down still knows the current value and can report a click. That context is the invisible wire connecting components the caller arranged. Props flow explicitly to configure <em>one</em> option (its icon, its value); context flows implicitly to coordinate <em>all</em> of them. That division is the whole trick.</p>
<p>Notice the <code>throw</code> in <code>Option</code>. Because these components only make sense together, a compound component should fail loudly when used apart. A bare <code>&lt;Select.Option&gt;</code> with no <code>&lt;Select&gt;</code> around it is a bug, and catching it with a clear error is far kinder than letting it silently render nothing.</p>
<h2>When it earns its place — and when it doesn&#39;t</h2>
<p>Compound components are not free. They&#39;re more code, more indirection, and a context that has to exist. So I don&#39;t reach for them by default. I reach for them when three signals show up together.</p>
<p><strong>The caller needs to control structure.</strong> If people keep asking to reorder parts, inject things between parts, or wrap parts — that&#39;s the tell. Structure control is what props are bad at and composition is good at.</p>
<p><strong>The parts are meaningless alone but meaningful together.</strong> <code>Tabs</code>/<code>TabList</code>/<code>Tab</code>/<code>TabPanel</code>. <code>Accordion</code>/<code>AccordionItem</code>. <code>Menu</code>/<code>MenuItem</code>/<code>MenuSeparator</code>. None of those children make sense on their own, and all of them need to coordinate through shared state. That&#39;s the exact shape compound components fit.</p>
<p><strong>There&#39;s real shared state to coordinate.</strong> If the children don&#39;t need to know about each other or a common value, you don&#39;t need the context, and you probably don&#39;t need this pattern — you just need components that accept <code>children</code>.</p>
<p>And when <em>not</em> to: a component with two or three stable props and no structural variation is perfect as a plain component. Wrapping a <code>&lt;Button&gt;</code> in a compound API is pure ceremony. The pattern earns its complexity only when the alternative is prop explosion. If you&#39;re not drowning in configuration props, you don&#39;t have the problem this solves.</p>
<h2>The mental shift that matters</h2>
<p>The reason I wanted to open the patterns run with this one is that compound components force a change in how you think, and that change outlasts the specific technique. The first time your component grows past a handful of props, you&#39;re not building a component anymore. You&#39;re designing an API that other people — including future you — will program against. And APIs are judged by how gracefully they handle the requests you didn&#39;t anticipate.</p>
<p>A prop-based component handles the unanticipated request by growing a new prop, and eventually collapses under its own configuration surface. A compound component handles it by letting the caller compose a solution you never had to predict — the divider you didn&#39;t plan for, the footer you didn&#39;t imagine — without you touching the component at all. That&#39;s the difference between a component people configure and a component people <em>build with</em>. The first one you maintain forever. The second one mostly maintains itself.</p>
<p>Next I want to push this one step further, to the pattern that takes the same idea and removes the last assumption a compound component still makes — that <em>you</em> own the markup at all. Headless components, render props, hooks-as-API: handing over behavior while giving up the UI entirely. If you&#39;ve got a shared component in your codebase with more than a dozen props, go count them, then ask how many are really the caller trying to control structure. I&#39;d bet the number is higher than you&#39;d like — tell me what you find.</p>
]]></content:encoded>
      <pubDate>Fri, 03 Jul 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Is Container vs Presentational Dead? What Survived the Hooks Era</title>
      <link>https://ma-x.im/blog/react-playbook-container-presentational</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-container-presentational</guid>
      <description>The pattern that taught a generation to split &apos;smart&apos; from &apos;dumb&apos; components got quietly declared obsolete when hooks arrived. It wasn&apos;t. The folder convention died. The idea underneath it is more useful now than ever — including in Server Components.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-container-presentational/cover.png" alt="Is Container vs Presentational Dead? What Survived the Hooks Era" /></p>
<p>Somewhere around 2019, &quot;container vs presentational components&quot; got quietly filed under <em>obsolete</em>. Hooks had landed, the man who popularized the pattern had publicly walked it back, and every other blog post declared that <code>containers/</code> and <code>components/</code> folders were a relic. Move on, we have <code>useEffect</code> now.</p>
<p>That take was half right, and the half it got wrong is the interesting half. The <em>folder convention</em> deserved to die. The <em>idea</em> it was pointing at — separate the component that knows about the world from the component that just renders — didn&#39;t die at all. It went underground, changed shape, and quietly became one of the most useful instincts you can have. It&#39;s the exact split I kept landing on in the <a href="https://ma-x.im/blog/react-playbook-composition-and-di">composition and DI article</a> without naming it. Time to name it.</p>
<h2>What the pattern actually said</h2>
<p>For anyone who missed the original: the classic pattern split components into two kinds.</p>
<p><strong>Container components</strong> knew how the app worked. They fetched data, held state, talked to the store, ran side effects — and rendered almost nothing themselves. <strong>Presentational components</strong> knew how things <em>looked</em>. They took props, rendered markup, fired callbacks, and knew nothing about where their data came from or where it went.</p>
<pre><code class="language-tsx">// the old shape — a container that fetches, and a dumb view it feeds
class UserListContainer extends React.Component {
  state = { users: [], loading: true };
  componentDidMount() {
    fetchUsers().then((users) =&gt; this.setState({ users, loading: false }));
  }
  render() {
    return &lt;UserList users={this.state.users} loading={this.state.loading} /&gt;;
  }
}

function UserList({ users, loading }) {
  if (loading) return &lt;Spinner /&gt;;
  return &lt;ul&gt;{users.map((u) =&gt; &lt;li key={u.id}&gt;{u.name}&lt;/li&gt;)}&lt;/ul&gt;;
}
</code></pre>
<p>The value was real: <code>UserList</code> was trivial to test and reuse because it was just a function of its props, and the messy stateful part lived in one place. The problem was equally real: it was <em>ceremony</em>. Every stateful component needed a class wrapper around a function, entire files existed only to fetch-and-pass, and the two halves were welded together one-to-one anyway. You wrote twice the components for a separation that was mostly bookkeeping.</p>
<h2>What hooks actually killed</h2>
<p>Then hooks arrived, and here&#39;s the precise thing they changed: <strong>you no longer need a separate component to hold state or effects.</strong> The whole mechanical reason containers existed — &quot;a function component can&#39;t have state, so wrap it in a class&quot; — evaporated. A single function component can now fetch, hold state, run effects, <em>and</em> render:</p>
<pre><code class="language-tsx">function UserList() {
  const { data: users, isLoading } = useQuery([&#39;users&#39;], fetchUsers);
  if (isLoading) return &lt;Spinner /&gt;;
  return &lt;ul&gt;{users.map((u) =&gt; &lt;li key={u.id}&gt;{u.name}&lt;/li&gt;)}&lt;/ul&gt;;
}
</code></pre>
<p>One component. No container. And this is genuinely better for most cases — the ceremony is gone. So when people say &quot;hooks killed container/presentational,&quot; this is what they mean, and they&#39;re right: hooks killed the <em>requirement</em> to physically split components just to have state.</p>
<p>But look closely at what they did <em>not</em> kill. <code>UserList</code> above now knows how to fetch. It reaches out and grabs its data — the exact reach-out anti-pattern from last time. It&#39;s convenient, and for a component you&#39;ll only ever use in one place, convenient is correct. The trouble is that people took &quot;hooks removed the ceremony&quot; and heard &quot;the separation was pointless.&quot; Those are not the same sentence.</p>
<h2>The idea that refused to die</h2>
<p>Strip away the class wrappers and the folder names, and the pattern was never really about containers. It was about a <em>boundary</em>: one side knows about the outside world — data, state, side effects, where things come from and go — and the other side just takes inputs and renders. Hooks removed the boilerplate for drawing that boundary. They did not remove the reasons you&#39;d want to.</p>
<p>You still want it every single time you need to:</p>
<ul>
<li><strong>test rendering without a backend</strong> — a pure view takes props, no mock server required;</li>
<li><strong>reuse the same look with different data</strong> — the same table fed by a query, a websocket, or fixtures;</li>
<li><strong>show it in Storybook</strong> — a component that fetches its own data can&#39;t live in a story without a whole app around it;</li>
<li><strong>let a designer or a different feature reuse the visuals</strong> without inheriting your data layer.</li>
</ul>
<p>So the modern move isn&#39;t <code>containers/</code> folders. It&#39;s this: <strong>write the one-component version by default, and split along the data/render seam the moment you need the render half to stand on its own.</strong> Same <code>UserList</code>, split not because a rule said so but because you now have a reason:</p>
<pre><code class="language-tsx">// the &quot;container&quot; is just a function that fetches and delegates — no class, no folder
function UserListView({ users, isLoading }: { users: User[]; isLoading: boolean }) {
  if (isLoading) return &lt;Spinner /&gt;;
  return &lt;ul&gt;{users.map((u) =&gt; &lt;li key={u.id}&gt;{u.name}&lt;/li&gt;)}&lt;/ul&gt;;
}

function UserList() {
  const { data = [], isLoading } = useQuery([&#39;users&#39;], fetchUsers);
  return &lt;UserListView users={data} isLoading={isLoading} /&gt;;
}
</code></pre>
<p>Notice there&#39;s no class, no <code>containers/</code> directory, no naming ritual. Just a component that knows about the world wrapping a component that doesn&#39;t. The pattern survived; the ceremony didn&#39;t. That&#39;s the whole story of the &quot;death&quot; of container/presentational.</p>
<h2>Where hooks made it fuzzier — and better</h2>
<p>The honest update is that hooks also gave us a <em>third</em> option the old pattern didn&#39;t have, and it&#39;s often the best one: <strong>extract the world-knowledge into a custom hook instead of a component.</strong></p>
<pre><code class="language-tsx">// the &quot;container logic&quot; is now a hook, not a wrapper component
function useUserList() {
  const { data = [], isLoading } = useQuery([&#39;users&#39;], fetchUsers);
  return { users: data, isLoading };
}

function UserList() {
  const { users, isLoading } = useUserList();
  if (isLoading) return &lt;Spinner /&gt;;
  return &lt;ul&gt;{users.map((u) =&gt; &lt;li key={u.id}&gt;{u.name}&lt;/li&gt;)}&lt;/ul&gt;;
}
</code></pre>
<p>Now the &quot;smart&quot; part is reusable on its own — any component can call <code>useUserList</code> — and the rendering stays simple. This is the container/presentational split turned sideways: instead of <em>smart component + dumb component</em>, it&#39;s <em>smart hook + component that renders</em>. For a lot of cases this is the sweet spot, because the reusable unit you actually wanted was the data logic, not a wrapper component. The boundary is still there. It just moved from a component seam to a hook seam.</p>
<p>So the real menu today isn&#39;t &quot;container or not.&quot; It&#39;s three options, and you pick by what you need to reuse or isolate:</p>
<ol>
<li><strong>One component</strong> that does it all — the default, for anything single-use.</li>
<li><strong>Smart hook + component</strong> — when the <em>logic</em> is what you want to reuse or test.</li>
<li><strong>Fetching component + pure view</strong> — when the <em>rendering</em> is what you want to reuse, test, or hand to Storybook.</li>
</ol>
<h2>Server Components made the split matter again</h2>
<p>Here&#39;s the twist nobody saw coming when they were busy declaring the pattern dead. React Server Components brought back a <em>hard, physical</em> version of exactly this boundary — and this time the framework enforces it.</p>
<p>A Server Component runs on the server, can be async, can hit the database directly, and ships no JavaScript to the browser. A Client Component (<code>&#39;use client&#39;</code>) runs in the browser and is the only thing that can hold state or handle events. The natural, idiomatic shape is: <strong>Server Components fetch and compose the data; Client Components take that data as props and handle interactivity.</strong></p>
<pre><code class="language-tsx">// app/users/page.tsx — Server Component: the &quot;container&quot;, now literally server-side
export default async function UsersPage() {
  const users = await db.user.findMany();   // no client JS, runs on the server
  return &lt;UserList users={users} /&gt;;
}

// UserList.tsx — &#39;use client&#39;: the interactive, presentational half
&#39;use client&#39;;
export function UserList({ users }: { users: User[] }) {
  const [query, setQuery] = useState(&#39;&#39;);
  const shown = users.filter((u) =&gt; u.name.includes(query));
  return (/* search box + list */);
}
</code></pre>
<p>Read that and tell me it isn&#39;t container/presentational wearing a new uniform. The server component <em>knows about the world</em> — it fetches, it composes. The client component <em>takes props and renders</em>, plus handles the interactivity that has to live in the browser. The pattern everyone buried in 2019 is now baked into the newest thing React ships, except now the boundary isn&#39;t a convention you can ignore — it&#39;s a wall the runtime draws for you, and crossing it wrong is a build error.</p>
<h2>What I actually believe about it</h2>
<p>The pattern isn&#39;t dead and it isn&#39;t alive in its original form either. What happened is more useful: the <em>rule</em> dissolved and the <em>instinct</em> got sharper. I don&#39;t make <code>containers/</code> folders. I don&#39;t wrap functions in classes. I don&#39;t split a component just because a style guide from 2016 says smart and dumb should be separate files.</p>
<p>What I do — every time — is keep one question live while I write a component: <em>does this thing know about the world, or does it just render?</em> When the answer is &quot;both, and I need one of those halves on its own,&quot; I split along that seam, and I reach for whichever tool fits — a hook, a pure view, or a Server/Client boundary. The genius of the old pattern was never the folders. It was teaching a generation to notice that seam at all. That part was never obsolete. It just stopped needing a name to be worth doing.</p>
<p>That closes out this run on architecture and boundaries — where code lives, which way it depends, how to size and wire a component, and now where the smart/dumb line actually falls. Next I want to move from <em>structure</em> to <em>patterns</em>: the reusable-component techniques — <a href="https://ma-x.im/blog/react-playbook-compound-components">compound components</a>, headless UIs, behavior-as-API — that decide whether the things you build get used or quietly rewritten. If you&#39;ve still got a <code>containers/</code> folder in a codebase somewhere, don&#39;t rename it in a panic — just notice whether the split it&#39;s drawing is one you&#39;d still draw today. Tell me what you find; I&#39;d bet half of them are pulling their weight and half are pure ceremony.</p>
]]></content:encoded>
      <pubDate>Tue, 30 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>How Big Should a Component Be? Composition, Decomposition, and Handing Over Dependencies</title>
      <link>https://ma-x.im/blog/react-playbook-composition-and-di</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-composition-and-di</guid>
      <description>Split a component too early and you get a maze of tiny files. Split too late and you get a 600-line monster. The real question isn&apos;t size — it&apos;s who owns which dependency, and whether the component reaches for it or is handed it.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-composition-and-di/cover.png" alt="How Big Should a Component Be? Composition, Decomposition, and Handing Over Dependencies" /></p>
<p>Every code review eventually gets the comment: &quot;this component is too big, split it up.&quot; It&#39;s good advice roughly half the time. The other half, someone dutifully chops a working 200-line component into eight files, and now understanding it means opening all eight, following props through four of them, and reassembling the whole thing in your head. The component wasn&#39;t fixed. It was smeared.</p>
<p>So &quot;split it up&quot; isn&#39;t the rule. Size is a symptom, not the disease. The real question underneath — the one the last three articles have been circling — is about <em>dependencies</em>: what does this component need to do its job, who gives it those things, and does the component go reach for them itself or get handed them? Get that right and the size mostly sorts itself out. Get it wrong and no amount of splitting helps.</p>
<h2>Decomposition isn&#39;t about line count</h2>
<p>Let me start with the part people get backwards. You don&#39;t split a component because it&#39;s long. You split it because it&#39;s doing more than one <em>thing</em> — because two different reasons to change are living in the same file.</p>
<p>Here&#39;s a component that&#39;s long but shouldn&#39;t be split:</p>
<pre><code class="language-tsx">function PolicyDetails({ policy }: { policy: Policy }) {
  // 180 lines of one coherent thing: rendering the details of a policy.
  // Header, status, coverage table, footer. All one job. All change
  // together, for the same reason (&quot;we redesigned the policy view&quot;).
}
</code></pre>
<p>It&#39;s 180 lines and I&#39;d leave it alone. There&#39;s one reason for it to change, and everything in it changes for that reason. Splitting it into <code>&lt;PolicyHeader&gt;</code>, <code>&lt;PolicyStatus&gt;</code>, <code>&lt;PolicyFooter&gt;</code> buys you nothing but four files to keep in sync — the classic mistake I wrote about in <a href="https://ma-x.im/blog/smeared-component">the smeared component</a>. Now here&#39;s a component that&#39;s <em>short</em> but absolutely should be split:</p>
<pre><code class="language-tsx">function UserBadge({ userId }: { userId: string }) {
  const { data: user } = useQuery([&#39;user&#39;, userId], () =&gt; fetchUser(userId));
  const theme = useContext(ThemeContext);
  const track = useAnalytics();

  useEffect(() =&gt; { track(&#39;badge_shown&#39;, { userId }); }, [userId]);

  if (!user) return &lt;Spinner /&gt;;
  return &lt;span style={{ color: theme.accent }}&gt;{user.name}&lt;/span&gt;;
}
</code></pre>
<p>Forty lines, and it&#39;s doing four unrelated jobs: fetching data, reading theme, firing analytics, and rendering. Four reasons to change, tangled together. This one I&#39;d split — not because it&#39;s big, but because it&#39;s <em>mixed</em>.</p>
<p>That&#39;s the actual rule: <strong>decompose along reasons to change, not along line count.</strong> One component, one job, one reason to change. A long file doing one thing is fine. A short file doing four things is a problem waiting to surprise you.</p>
<h2>The reach-out anti-pattern</h2>
<p>Look again at <code>UserBadge</code>. The thing that makes it hard to reuse and impossible to test in isolation isn&#39;t its length. It&#39;s that it <em>reaches out</em> and grabs everything it needs from the ambient environment: it calls <code>fetchUser</code> directly, pulls theme from a context, grabs the analytics client from a hook. It&#39;s wired straight into the world.</p>
<p>This is the component equivalent of an arrow pointing the wrong way — the exact problem from the <a href="https://ma-x.im/blog/react-playbook-module-dependencies">dependency-direction article</a>, now one level down, inside a single component. <code>UserBadge</code> claims to be a small presentational thing, but it depends on your data layer, your theme system, and your analytics pipeline. Drop it into a Storybook story or a test and it explodes, because it demands a whole app around it just to render a name.</p>
<p>You feel this the moment you try to reuse it. &quot;I just want to show a user&#39;s name in a different color&quot; — but you can&#39;t, because the color is hard-wired to <code>ThemeContext.accent</code>, and the name is hard-wired to <em>this</em> query. The component grabs its dependencies instead of accepting them, so it only works in the one spot it was born in.</p>
<h2>Hand it over instead</h2>
<p>The fix is old and boring and it&#39;s called dependency injection, which sounds like a Java framework and is actually just this: <strong>don&#39;t let a component fetch its own dependencies — hand them in.</strong> On the frontend, &quot;inject&quot; mostly means &quot;pass as a prop&quot; or &quot;provide through context,&quot; and the entire idea is that the component stops knowing <em>where</em> things come from.</p>
<pre><code class="language-tsx">// UserBadge now just renders. It&#39;s handed everything it needs.
function UserBadge({ name, color }: { name: string; color: string }) {
  return &lt;span style={{ color }}&gt;{name}&lt;/span&gt;;
}
</code></pre>
<p>That&#39;s it. It doesn&#39;t fetch, doesn&#39;t read context, doesn&#39;t track. It takes a name and a color and renders. Now it works in a test with two strings, works in Storybook with no providers, works anywhere. The fetching, theming, and analytics didn&#39;t disappear — they moved <em>up</em> to a component whose job is exactly that wiring:</p>
<pre><code class="language-tsx">function ConnectedUserBadge({ userId }: { userId: string }) {
  const { data: user } = useQuery([&#39;user&#39;, userId], () =&gt; fetchUser(userId));
  const theme = useContext(ThemeContext);
  const track = useAnalytics();

  useEffect(() =&gt; { track(&#39;badge_shown&#39;, { userId }); }, [userId, track]);

  if (!user) return &lt;Spinner /&gt;;
  return &lt;UserBadge name={user.name} color={theme.accent} /&gt;;
}
</code></pre>
<p>We split <code>UserBadge</code> — but notice <em>how</em> we split it. Not into &quot;the top half and the bottom half.&quot; Along a dependency seam: one component that knows about the outside world (fetching, context, side effects), and one that knows nothing and just renders what it&#39;s given. That seam is the useful cut. The line-count cut isn&#39;t.</p>
<h2>Injection is a spectrum, not a switch</h2>
<p>You don&#39;t have to reach for a DI container or some ceremony. Frontend dependency injection is mostly choosing, per dependency, how far to push it out of the component. From least to most flexible:</p>
<p><strong>Hard-coded (no injection).</strong> The component imports and calls the thing directly. Fine for genuinely stable, app-agnostic dependencies — a component importing <code>clsx</code> or a pure <code>formatDate</code> doesn&#39;t need those injected. Not everything is a dependency worth managing.</p>
<p><strong>Props.</strong> The default and the one you&#39;ll use most. Pass the value, the callback, the data. Explicit, type-checked, trivially testable. If you&#39;re not sure how to inject something, pass it as a prop and stop overthinking it.</p>
<pre><code class="language-tsx">// the data source is injected — the list doesn&#39;t know or care where rows come from
function DataTable&lt;T&gt;({ rows, onRowClick }: { rows: T[]; onRowClick: (row: T) =&gt; void }) { /* ... */ }
</code></pre>
<p><strong>Context.</strong> For dependencies that are truly cross-cutting and tedious to thread — theme, current user, a query client, an analytics sink. Context is injection at a distance: the component pulls from context, but <em>what&#39;s in that context</em> is decided at the provider, so tests and stories can hand it a fake. The danger is overusing it — every context dependency is an invisible input that doesn&#39;t show up in the props, and a component that reads six contexts is just as wired-in as one that imports six modules. Use context for the few things that are genuinely app-wide, and props for everything else.</p>
<p><strong>Render props / children as a function / slots.</strong> Inject <em>behavior or markup</em>, not just values. This is how you build something reusable without it knowing what it renders — a <code>&lt;List&gt;</code> that takes a <code>renderItem</code>, a <code>&lt;Form&gt;</code> that takes children. It&#39;s dependency injection where the dependency is a piece of UI.</p>
<p>The skill isn&#39;t picking one. It&#39;s matching each dependency to the lightest mechanism that still lets you swap it when you need to. Most things: props. A few app-wide things: context. Behavior you want to vary: a function. Genuinely fixed, generic utilities: just import them.</p>
<h2>The payoff you actually feel</h2>
<p>This all sounds abstract until the day it isn&#39;t. Three moments where injecting dependencies instead of grabbing them pays for itself:</p>
<p><strong>Testing.</strong> A component handed its dependencies is a component you test with plain values — no mocking the fetch layer, no wrapping in five providers, no fighting the framework. <code>render(&lt;UserBadge name=&quot;Ada&quot; color=&quot;#22c55e&quot; /&gt;)</code> and you&#39;re done. The reach-out version needs a mock server and a provider tree just to assert on a name.</p>
<p><strong>Reuse.</strong> The whole reason the &quot;small&quot; <code>UserBadge</code> couldn&#39;t be reused was that it grabbed its color from one specific context. Hand the color in and it drops into any screen, any theme, any story. Injected dependencies are what make a component portable — which, not coincidentally, is the same <a href="https://ma-x.im/blog/react-playbook-shared-code">portability promise</a> that decides whether something belongs in <code>shared/</code> at all.</p>
<p><strong>Changing your mind.</strong> Swap <code>fetchUser</code> for a different endpoint, replace the analytics provider, feed the table rows from a websocket instead of a query — and the leaf components don&#39;t change at all, because they never knew where their data came from. You only touch the wiring layer. The thing you were afraid to change turns out to have one owner, in one place.</p>
<h2>The rule I actually carry around</h2>
<p>I&#39;ve stopped asking &quot;is this component too big.&quot; It&#39;s the wrong question and it leads to smearing. The questions I ask instead are two: <em>how many reasons does this component have to change</em> (that tells me whether to split, and where), and <em>does it reach for its dependencies or get handed them</em> (that tells me whether it&#39;ll be testable and reusable, or welded to one spot).</p>
<p>Composition, in the end, isn&#39;t about making things small. It&#39;s about making the seams fall in the right places — cutting along dependencies, not along line counts — and being deliberate about which component owns the wiring and which just renders what it&#39;s told. Do that and your big components stay big and fine, your small ones stay focused, and the word &quot;reusable&quot; starts meaning something instead of being a hope you wrote in a PR description.</p>
<p>Next I want to take this exact idea — the split between &quot;knows about the world&quot; and &quot;just renders&quot; — and hold it up against the pattern that&#39;s been arguing about it for a decade: <a href="https://ma-x.im/blog/react-playbook-container-presentational">container versus presentational components</a>, and whether that split still makes sense now that we have hooks and Server Components. If you&#39;ve got a component you&#39;re scared to reuse, look at what it reaches for instead of what it&#39;s handed. That list is usually the whole reason — tell me what&#39;s on yours.</p>
]]></content:encoded>
      <pubDate>Fri, 26 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>The Direction of the Arrows: Dependencies Are Your Real Architecture</title>
      <link>https://ma-x.im/blog/react-playbook-module-dependencies</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-module-dependencies</guid>
      <description>You can draw whatever folder diagram you like. The honest picture of your app is which module imports which — the direction of the arrows. Get that wrong and no folder structure will save you.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-module-dependencies/cover.png" alt="The Direction of the Arrows: Dependencies Are Your Real Architecture" /></p>
<p>Draw your app&#39;s architecture on a whiteboard and it always looks clean. Neat boxes, sensible names, tidy layers stacked top to bottom. Everyone nods. Then you open the code and it doesn&#39;t match the drawing at all — a UI component reaches into a data-fetching file, a &quot;shared&quot; helper imports a specific feature, two features import each other through a back door nobody remembers adding.</p>
<p>The whiteboard is the architecture you <em>wish</em> you had. The import statements are the architecture you <em>actually</em> have. And the gap between them is where most large React apps quietly rot.</p>
<p>I&#39;ve spent the last two articles on where code lives — the <a href="https://ma-x.im/blog/react-playbook-shared-code"><code>shared/</code> folder</a> and <a href="https://ma-x.im/blog/react-playbook-barrel-files">barrel files</a>. This one is about the thing underneath both: not where a module sits, but which way it <em>points</em>. Who is allowed to import whom. Because that — the direction of the arrows — is the real shape of your app.</p>
<h2>Your folders lie, your imports don&#39;t</h2>
<p>Here&#39;s the uncomfortable part. Folder structure is a suggestion. You can put a file in <code>shared/</code> and it means nothing about how coupled it is — what matters is what it imports and who imports it. Two apps with identical folder trees can have completely different architectures, because architecture isn&#39;t the tree. It&#39;s the graph.</p>
<p>Every <code>import</code> statement is a directed edge: &quot;this module depends on that one.&quot; Collect all of them and you get your dependency graph — the one true map of your app. And that map answers the questions folders can&#39;t:</p>
<ul>
<li>If I change this file, what breaks? (everything that points <em>at</em> it)</li>
<li>Can I delete this feature cleanly? (only if nothing outside points into it)</li>
<li>Why is this &quot;small&quot; component impossible to reuse? (because it points at half the app)</li>
<li>Why did one route pull in the entire bundle? (follow the arrows — you&#39;ll find a bad one)</li>
</ul>
<p>You already met that last one in the barrel article. That wasn&#39;t really a barrel problem. It was an <em>arrow</em> problem — an import pointing somewhere it shouldn&#39;t, and a barrel making it easy.</p>
<blockquote>
<p>Folders describe intent. Imports describe reality. When they disagree, reality wins.</p>
</blockquote>
<h2>Arrows have a correct direction</h2>
<p>The single most useful architectural rule I know fits in one sentence: <strong>dependencies should point in one direction — from specific to general, from volatile to stable, never back.</strong></p>
<p>Think of your app in rough layers, top to bottom:</p>
<pre><code>app          ← wires everything together
features     ← &quot;edit policy&quot;, &quot;create claim&quot; — business use-cases
entities     ← domain models: Policy, Claim, User
shared       ← generic, app-agnostic: Button, formatDate, http client
</code></pre>
<p>The rule is simple: <strong>arrows only ever point down.</strong> A feature may import an entity. An entity may import from <code>shared</code>. <code>shared</code> imports <em>nothing</em> from above it — not an entity, definitely not a feature.</p>
<p>Why down and not up? Because the lower you go, the more stable and general the code is. <code>shared/Button</code> doesn&#39;t know your app sells insurance. <code>entities/Policy</code> knows about policies but not about the specific &quot;renew a lapsed policy&quot; screen. The <code>edit-policy</code> feature knows the most and changes the most. If a stable module (<code>Button</code>) points <em>up</em> at a volatile one (<code>edit-policy</code>), then every time that feature changes, your generic button is at risk — and it&#39;s no longer generic. You just welded the most reusable thing in your app to the least reusable one.</p>
<p>The moment <code>shared</code> imports a feature, the word &quot;shared&quot; becomes a lie. This is the mechanical reason the <code>shared/</code> folder turns into a junk drawer: not because people are messy, but because they let arrows point the wrong way, and an upward arrow is exactly how a generic folder gets chained to a specific feature.</p>
<h2>The two arrows that kill you</h2>
<p>There are two directions of arrow that do almost all the damage. Learn to see them and you&#39;ve caught 90% of architectural decay.</p>
<p><strong>The upward arrow — general depending on specific.</strong> A shared utility importing a feature. An entity importing a screen. A design-system component importing app-specific business logic. Every one of these takes something that was supposed to be reusable and staples it to something that isn&#39;t.</p>
<pre><code class="language-ts">// shared/lib/format.ts  — a &quot;generic&quot; formatter
import { POLICY_STATUS_LABELS } from &#39;@/features/edit-policy/constants&#39;;
//                                    ^^^^^^^^^^^^^^^^^^^^^^ upward arrow

export function formatStatus(code: string) {
  return POLICY_STATUS_LABELS[code] ?? code;
}
</code></pre>
<p>This looks harmless. It compiles, it works, the PR gets approved. But <code>shared/lib/format</code> now depends on <code>edit-policy</code>. Import that formatter anywhere — a totally unrelated screen — and you&#39;ve dragged <code>edit-policy</code> in with it. &quot;Shared&quot; now means &quot;shares a dependency on one specific feature.&quot; The fix is to push the specific thing up to where it belongs and keep the shared thing ignorant:</p>
<pre><code class="language-ts">// shared/lib/format.ts — knows nothing about policies
export function formatStatus(code: string, labels: Record&lt;string, string&gt;) {
  return labels[code] ?? code;
}

// features/edit-policy/... — the feature owns its own labels
formatStatus(policy.status, POLICY_STATUS_LABELS);
</code></pre>
<p>The generic module stayed generic. The specific knowledge stayed in the feature. The arrow points down again.</p>
<p><strong>The sideways arrow — feature importing feature.</strong> <code>edit-policy</code> reaches directly into <code>billing-dashboard</code>. Now you can&#39;t touch one without understanding the other, you can&#39;t delete either cleanly, and if <code>billing-dashboard</code> also imports <code>edit-policy</code> back, you&#39;ve got a cycle — two modules that can only be understood as one tangled unit. Cross-feature imports are how a codebase where every feature was supposed to be independent becomes one where nothing is.</p>
<p>The fix isn&#39;t &quot;never let features talk.&quot; It&#39;s <em>how</em> they talk. Pull the shared concept down into an entity both can depend on, or lift the coordination up into the <code>app</code> layer that&#39;s allowed to know about both. Features don&#39;t import each other sideways; they meet in a layer above or below. Arrows down, never across.</p>
<h2>A feature&#39;s public API is the only door in</h2>
<p>Here&#39;s what makes the &quot;arrows down&quot; rule enforceable instead of a vibe: <strong>each feature exposes one public entry point, and nobody imports past it.</strong></p>
<pre><code>features/edit-policy/
  index.ts            ← the ONLY thing outsiders may import
  ui/EditPolicyForm.tsx
  model/useEditPolicy.ts
  model/validation.ts   ← private
  lib/mapPolicyDto.ts   ← private
</code></pre>
<pre><code class="language-ts">// features/edit-policy/index.ts — deliberate public surface
export { EditPolicyForm } from &#39;./ui/EditPolicyForm&#39;;
export { useEditPolicy } from &#39;./model/useEditPolicy&#39;;
// validation, DTO mapping, internal helpers stay private
</code></pre>
<p>Anything outside the feature imports from <code>@/features/edit-policy</code> and nothing deeper. The insides — validation rules, DTO mapping, private hooks — are free to change, because from the outside they don&#39;t exist. This is the <em>good</em> use of a barrel I argued for last time: not to shorten imports, but to draw a wall with exactly one door in it.</p>
<p>Reach past that door — <code>import { validatePolicy } from &#39;@/features/edit-policy/model/validation&#39;</code> — and you&#39;ve done two bad things at once: coupled to an internal that was never promised to stay, and created an arrow that no longer respects the feature&#39;s boundary. The public API is what turns &quot;arrows point down&quot; from a hope into a checkable rule. There&#39;s a door. You use the door.</p>
<h2>Make the graph impossible to get wrong</h2>
<p>You will not hold this in your head. Nobody does. On a real team, someone adds an upward import at 6pm on a Friday, it works, it ships, and the arrow is now part of your architecture forever. Good intentions don&#39;t scan the dependency graph. Tooling does.</p>
<p>I let a linter own the rule. <code>eslint-plugin-import</code> with <code>no-restricted-imports</code>, or a boundaries plugin (<code>eslint-plugin-boundaries</code>, or the import rules in Biome), encoding the layer directions as actual errors:</p>
<pre><code class="language-js">// eslint — shared may not import upward, features may not import each other
&#39;no-restricted-imports&#39;: [&#39;error&#39;, {
  patterns: [
    { group: [&#39;@/features/*&#39;, &#39;@/entities/*&#39;], message:
      &#39;shared/ must not import from features or entities — arrows point down only.&#39; },
  ],
}],
</code></pre>
<p>Now the wrong arrow doesn&#39;t get a code review debate. It gets a red squiggle before the commit. The architecture stops being a document people forget and becomes a constraint the tooling enforces. That&#39;s the whole game: an architectural rule that isn&#39;t mechanically enforced is a rule that&#39;s already being broken somewhere you haven&#39;t looked.</p>
<p>And once the arrows are constrained, a dependency-graph visualiser (<code>madge</code>, <code>dependency-cruiser</code>, or your bundler&#39;s analyzer) stops being a curiosity and becomes a health check. Run <code>madge --circular</code> and it lists your cycles — every one a place where two modules secretly became one. A clean graph with arrows all pointing down is not an aesthetic preference. It&#39;s the difference between a change that touches one file and a change that touches forty.</p>
<h2>Why this is the article under the others</h2>
<p>The <code>shared/</code> folder turns into a junk drawer because people let it depend upward. Barrel files blow up your bundle because they make a bad arrow cheap to draw. Features become impossible to delete because they point sideways at each other. Every one of those is the same disease wearing a different symptom: <strong>an arrow going the wrong way.</strong></p>
<p>So when I evaluate an architecture now, I don&#39;t start with the folder tree — that&#39;s the story the code tells about itself. I start with the graph: pull the imports, look at the direction of the arrows, and find the ones pointing up or sideways. Those arrows are the whole diagnosis. Fix them and the folders mostly sort themselves out. Ignore them and you can reorganize folders every quarter and still be stuck in the same tangle, because you were rearranging the boxes while the arrows — the thing that actually mattered — stayed exactly where they were.</p>
<p>Next I want to turn from <em>where the arrows point</em> to <em>how big the boxes should be</em> — <a href="https://ma-x.im/blog/react-playbook-composition-and-di">composition versus decomposition</a>, and how to hand a component its dependencies instead of letting it reach out and grab them. If you want a fast, slightly alarming read on your own app tonight, run <code>madge --circular src</code> and count the cycles. Tell me the number — I&#39;m curious whether you&#39;re prouder or more horrified than you expected.</p>
]]></content:encoded>
      <pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>FSD</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Barrel Files: The Import You Love and the Bundle You Hate</title>
      <link>https://ma-x.im/blog/react-playbook-barrel-files</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-barrel-files</guid>
      <description>A barrel file makes imports look beautiful and can quietly drag half your app into a bundle that only needed one function. Here&apos;s what index.ts actually does, when it earns its place, and when it&apos;s a trap wearing a clean interface.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-barrel-files/cover.png" alt="Barrel Files: The Import You Love and the Bundle You Hate" /></p>
<p>There&#39;s a specific kind of import that feels like good engineering:</p>
<pre><code class="language-ts">import { Button, Card, Modal, Tooltip } from &#39;@/shared/ui&#39;;
</code></pre>
<p>One line. One path. Clean. Compare it to the honest version:</p>
<pre><code class="language-ts">import { Button } from &#39;@/shared/ui/Button&#39;;
import { Card } from &#39;@/shared/ui/Card&#39;;
import { Modal } from &#39;@/shared/ui/Modal&#39;;
import { Tooltip } from &#39;@/shared/ui/Tooltip&#39;;
</code></pre>
<p>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 <code>index.ts</code> that re-exports everything, and the whole folder gets a single front door. That file is a <em>barrel</em>, and it&#39;s one of the most quietly expensive habits in a React codebase. Not because it&#39;s wrong. Because it&#39;s right just often enough that people stop questioning it.</p>
<p>I brought this up at the end of the <a href="https://ma-x.im/blog/react-playbook-shared-code">shared/ article</a> and promised to come back to it. Here&#39;s the come-back.</p>
<h2>What a barrel actually is</h2>
<p>A barrel is just a file — almost always <code>index.ts</code> — whose only job is to re-export other modules:</p>
<pre><code class="language-ts">// shared/ui/index.ts
export { Button } from &#39;./Button&#39;;
export { Card } from &#39;./Card&#39;;
export { Modal } from &#39;./Modal&#39;;
export { Tooltip } from &#39;./Tooltip&#39;;
</code></pre>
<p>Now <code>@/shared/ui</code> 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 <code>Button.tsx</code> 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&#39;s the same &quot;public API of a folder&quot; idea that makes feature boundaries work.</p>
<p>So far this sounds like something you&#39;d want everywhere. That&#39;s exactly the trap.</p>
<h2>The cost hides where you&#39;re not looking</h2>
<p>Here&#39;s the problem. When you write <code>import { Button } from &#39;@/shared/ui&#39;</code>, you are not importing <code>Button</code>. You are importing the <em>barrel</em>, and the barrel imports <strong>everything</strong>. <code>Button</code>, <code>Card</code>, <code>Modal</code>, <code>Tooltip</code>, and whatever those pull in — the whole folder loads to hand you one component.</p>
<p>&quot;But tree-shaking removes the unused ones,&quot; you say. Sometimes. Tree-shaking is real, but it&#39;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 <code>package.json</code> — and the bundler can no longer prove the other exports are safe to drop. To stay correct, it keeps them. Your one-line <code>Button</code> import just pulled in <code>Modal</code>, its focus-trap library, and the animation dependency <code>Tooltip</code> uses.</p>
<p>You won&#39;t see it in code review. The import line looks identical whether the tree shakes cleanly or not. You&#39;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.</p>
<p>There&#39;s a second cost that shows up sooner: build and dev-server speed. Every barrel is a fan-out. Import one thing through <code>@/shared/ui</code> and the module graph has to resolve every file the barrel touches, and every file <em>those</em> 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.</p>
<blockquote>
<p>A barrel trades a real cost you can&#39;t see for a cosmetic benefit you can.</p>
</blockquote>
<h2>The worst offender: the app-wide barrel</h2>
<p>The pattern that does the most damage is the ambition to have one barrel at a high level that re-exports a whole layer:</p>
<pre><code class="language-ts">// features/index.ts — please don&#39;t
export * from &#39;./edit-policy&#39;;
export * from &#39;./create-claim&#39;;
export * from &#39;./billing-dashboard&#39;;
export * from &#39;./user-settings&#39;;
// ...twenty more
</code></pre>
<p>Now <code>import { EditPolicyForm } from &#39;@/features&#39;</code> reaches into a barrel that transitively imports <em>every feature in the app</em>. Import one feature and you&#39;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&#39;s view everything is connected to everything through this one file. I&#39;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.</p>
<p>The <code>export *</code> form is especially nasty. It&#39;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 (<code>export { X } from &#39;./x&#39;</code>) are at least explicit. <code>export *</code> is a blank check.</p>
<h2>When a barrel earns its place</h2>
<p>I&#39;m not anti-barrel. I use them. The question is never &quot;barrels: yes or no.&quot; It&#39;s &quot;does this specific barrel pay for itself.&quot; A barrel earns its place when three things are true at once.</p>
<p><strong>It wraps a genuine, stable public API.</strong> A design-system package where <code>Button</code>, <code>Card</code>, and <code>Modal</code> are <em>the whole point</em> and consumers legitimately pull from across it — that&#39;s a real interface, and a barrel is the right way to express it. The barrel isn&#39;t hiding a mess; it&#39;s naming a surface that was always meant to be used as a whole.</p>
<p><strong>It&#39;s small and internally cohesive.</strong> A barrel over five related components in one feature is cheap — importing one probably means you&#39;re near the others anyway, and the fan-out is tiny. The cost of a barrel scales with what&#39;s behind it. Small folder, small cost.</p>
<p><strong>Nothing behind it has surprising weight or side effects.</strong> 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.</p>
<p>Miss any of those and the barrel is working against you. A top-level <code>@/features</code> barrel fails all three: not a stable surface, not small, and full of heavy things. A <code>shared/ui/index.ts</code> over a real design system passes all three. Same mechanism, opposite verdict — which is the whole reason &quot;always use barrels&quot; and &quot;never use barrels&quot; are both wrong.</p>
<h2>What I actually do</h2>
<p>My rules are boring, which is how I like architecture rules.</p>
<p><strong>Import from the specific file by default.</strong> <code>import { Button } from &#39;@/shared/ui/Button&#39;</code> 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.</p>
<pre><code class="language-ts">// default: reach for the exact thing
import { Button } from &#39;@/shared/ui/Button&#39;;
import { formatCurrency } from &#39;@/shared/lib/money&#39;;
</code></pre>
<p><strong>Add a barrel only at a real boundary I&#39;ve decided to publish.</strong> A feature&#39;s public entry point, a design-system package&#39;s root — places where &quot;this is the surface, use it as one&quot; is a deliberate architectural statement, not a convenience. And when I do, I write explicit named re-exports, never <code>export *</code>:</p>
<pre><code class="language-ts">// features/edit-policy/index.ts — a deliberate public surface
export { EditPolicyForm } from &#39;./ui/EditPolicyForm&#39;;
export { useEditPolicy } from &#39;./model/useEditPolicy&#39;;
// internal helpers are NOT re-exported — they stay private
</code></pre>
<p>That barrel is doing architectural work: it defines what the feature exposes and hides everything else, so the rest of the app can&#39;t reach into the feature&#39;s guts. That&#39;s a barrel as an <em>encapsulation boundary</em>, which is worth having. A barrel as a <em>typing convenience</em> usually isn&#39;t.</p>
<p><strong>Never barrel a whole layer.</strong> No <code>@/features</code>, no <code>@/entities</code> mega-barrel. Layers are not modules; they&#39;re categories. Giving a category a single import door is how code-splitting dies.</p>
<p><strong>Verify with the analyzer, not with faith.</strong> Tree-shaking is a claim, not a guarantee. Run a bundle analyzer, find the route that&#39;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.</p>
<h2>The real lesson isn&#39;t about barrels</h2>
<p>Strip away the mechanics and this is the same idea as the <code>shared/</code> article, pointed at a different symptom. A barrel is a public API for a folder. Public APIs are worth having when there&#39;s a real boundary to protect and a real surface to name. They&#39;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.</p>
<p>So the question to ask isn&#39;t &quot;should this folder have an index.ts.&quot; It&#39;s &quot;does this folder have a public API worth defining — and am I willing to pay the fan-out to define it.&quot; When the answer is yes, barrel it, name the surface, and hide the rest. When the answer is &quot;I just want shorter imports,&quot; reach for the specific file and move on. The prettier import was never worth the bundle you couldn&#39;t see.</p>
<p>Next in this stretch on architecture, I want to get at the thing underneath both of these articles: <a href="https://ma-x.im/blog/react-playbook-module-dependencies">how modules should <em>depend</em> on each other</a> at all — the direction of the arrows, who&#39;s allowed to import whom, and why a dependency graph is the most honest picture of your app&#39;s health. If you&#39;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&#39;m always curious how far the fan-out reached.</p>
]]></content:encoded>
      <pubDate>Fri, 19 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>Performance</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>The shared/ Folder Is Not a Place, It&apos;s a Promise</title>
      <link>https://ma-x.im/blog/react-playbook-shared-code</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-shared-code</guid>
      <description>Every codebase grows a shared/ folder, and given enough time every shared/ folder becomes a junk drawer. Not because the team got lazy — because the folder was never given a rule. Here&apos;s why it rots, and how to keep it boring.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-shared-code/cover.png" alt="The shared/ Folder Is Not a Place, It&apos;s a Promise" /></p>
<p>Open the <code>shared/</code> folder in almost any React app that&#39;s been alive for a year. Count the files. Then try to explain, out loud, what the folder is <em>for</em> — not what&#39;s in it, what it&#39;s <em>for</em>. You&#39;ll stall. There&#39;s a date formatter, sure, and a Button. But there&#39;s also <code>useUserPermissions</code>, a <code>formatPolicyNumber</code>, half a checkout state machine, a <code>legacyApiAdapter</code>, and three hooks whose names start with <code>use</code> and end in a shrug.</p>
<p>That folder didn&#39;t get that way because someone was careless. It got that way because <code>shared/</code> is the one folder in your app with no rule attached to it. Everything else has a reason to reject a file. <code>shared/</code> accepts everything. And a folder that accepts everything isn&#39;t a category — it&#39;s a drain.</p>
<p>I already flagged this in the <a href="https://ma-x.im/blog/react-playbook-code-structure">code structure article</a>: <code>shared/</code> 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&#39;ve already lost.</p>
<h2>Why the drawer fills up</h2>
<p>The junk drawer isn&#39;t a discipline problem. If it were, telling people &quot;be more careful about <code>shared/</code>&quot; would fix it. It never does. The rot is structural, and it comes from three forces that all push the same direction.</p>
<p><strong>The naming trap.</strong> You write a hook. Where does it go? If it has an obvious owner — <code>features/edit-policy</code> — the answer is easy. But a lot of code sits in the awkward middle: not clearly owned, not clearly generic. <code>useDebouncedValue</code> is generic. <code>usePolicyDraftAutosave</code> is owned. But <code>useAutosave</code>, the one that <em>started</em> generic and quietly grew a <code>policyId</code> parameter — that one has no obvious home. So it goes to <code>shared/</code>, because <code>shared/</code> never says no.</p>
<p><strong>The reuse reflex.</strong> The moment a second file wants something, the instinct is to &quot;lift it up&quot; so both can reach it. This feels like good engineering. It&#39;s usually premature. Two components in the same feature using one helper is not a reason to move that helper three layers up into <code>shared/lib</code> — it&#39;s a reason to keep it <em>inside the feature</em>. Reuse across a boundary is a real signal. Reuse within a boundary is just normal code living near the thing that uses it.</p>
<p><strong>The path of least resistance.</strong> <code>shared/</code> 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&#39;re in a hurry and don&#39;t want to think about ownership. No import will ever break. And that&#39;s exactly the problem: the folder that&#39;s safe to import from is also the folder that&#39;s frictionless to dump into.</p>
<p>Put those three together and you don&#39;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 <code>shared/</code> is where &quot;I&#39;ll sort it later&quot; goes to never be sorted.</p>
<blockquote>
<p><code>shared/</code> is not a place you put things. It&#39;s a promise you make about them.</p>
</blockquote>
<p>That reframing is the whole article. The promise is: <em>this code is domain-agnostic and portable — it would make sense in a completely different product.</em> Every file in <code>shared/</code> is you signing that promise. The drawer fills up because people put things there without noticing they&#39;re signing anything.</p>
<h2>The test that actually works</h2>
<p>The rule I gave in the structure article still holds, and it&#39;s the only test I&#39;ve found that survives contact with a real team:</p>
<blockquote>
<p>A file belongs in <code>shared/</code> only if you could move it to a <em>different product at the same company</em> and it would still make sense.</p>
</blockquote>
<p>A <code>Button</code>? Moves fine. A <code>formatCurrency</code>? Moves fine. An <code>http</code> client with your auth interceptor? Borderline — it moves if the auth scheme is company-wide, stays if it&#39;s app-specific. A <code>useUserPermissions</code> hook that knows your permission strings? Doesn&#39;t move. It&#39;s dressed up as generic — it takes a permission key, it returns a boolean, it <em>looks</em> reusable — but it&#39;s welded to your domain&#39;s idea of what a permission is. That&#39;s not shared code. That&#39;s domain code wearing a generic costume.</p>
<p>Here&#39;s the tell, in code. This looks shared:</p>
<pre><code class="language-ts">// shared/hooks/usePermission.ts
export function usePermission(key: string): boolean {
  const { permissions } = useCurrentUser();
  return permissions.includes(key);
}
</code></pre>
<p>It isn&#39;t. <code>useCurrentUser</code> is a domain concept. The moment this hook reaches sideways into <code>entities/user</code>, it stopped being portable — it now depends on <em>your</em> app&#39;s notion of a user. Move it to a different product and it breaks. It belongs in <code>entities/user</code>, exported as part of that entity&#39;s public surface. What&#39;s left in <code>shared/</code> should be the genuinely dumb primitive:</p>
<pre><code class="language-ts">// shared/lib/includes.ts — portable, knows nothing
export const hasKey = (keys: string[], key: string) =&gt; keys.includes(key);
</code></pre>
<p>The portability test isn&#39;t about whether code <em>can</em> be reused. Almost anything can be reused if you squint. It&#39;s about whether the code <em>knows about your domain</em>. Knowledge is the boundary, not usage.</p>
<h2>Make the boundary a wall, not a suggestion</h2>
<p>A rule that lives only in a code review is a rule you will lose. Reviewers get tired. New people don&#39;t know it. The pressure that fills the drawer doesn&#39;t take weekends off. So the direction of dependencies — lower layers never importing from higher ones — has to be enforced by tooling, not vigilance.</p>
<p>ESLint&#39;s <code>no-restricted-imports</code> gets you most of the way with zero extra dependencies:</p>
<pre><code class="language-js">// eslint.config.js
export default [
  {
    files: [&#39;src/shared/**&#39;],
    rules: {
      &#39;no-restricted-imports&#39;: [
        &#39;error&#39;,
        {
          patterns: [
            {
              group: [
                &#39;@/features/*&#39;,
                &#39;@/entities/*&#39;,
                &#39;@/widgets/*&#39;,
                &#39;@/pages/*&#39;,
                &#39;@/app/*&#39;,
              ],
              message:
                &#39;shared/ must not import from higher layers. If this file needs domain code, it does not belong in shared/.&#39;,
            },
          ],
        },
      ],
    },
  },
];
</code></pre>
<p>Now the <code>usePermission</code> example from earlier fails the build the instant it imports <code>useCurrentUser</code>. The error message isn&#39;t just &quot;no&quot; — it <em>explains the promise</em>: if this file needs domain code, it doesn&#39;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&#39;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.</p>
<p>The point isn&#39;t the exact config. It&#39;s that the promise stops depending on everyone remembering it. You made <code>shared/</code> say no. That single change is the difference between a folder that stays small and one that quietly triples every quarter.</p>
<h2>Digging out of one you already have</h2>
<p>Prevention is easy to talk about when the drawer is empty. Most of the time it isn&#39;t. You&#39;ve inherited forty files and a folder that&#39;s load-bearing in ways nobody documented. You can&#39;t stop shipping features to go on an architecture retreat, and a big-bang &quot;fix shared/&quot; pull request is a merge-conflict machine that reviewers will rubber-stamp out of exhaustion. So don&#39;t do that. Dig out incrementally.</p>
<p><strong>First, audit without moving anything.</strong> Go through <code>shared/</code> file by file and tag each one with a comment — three buckets only:</p>
<ul>
<li><strong>Portable</strong> — passes the test. Leave it. This is what <code>shared/</code> is <em>for</em>, and seeing what legitimately qualifies is calming.</li>
<li><strong>Owned</strong> — knows about a domain concept. It has a real home in an <code>entity</code> or a <code>feature</code>; it&#39;s just sitting in the wrong place.</li>
<li><strong>Split</strong> — genuinely two things fused together: a portable core with a domain-specific wrapper grown around it. The <code>useAutosave</code>-that-grew-a-<code>policyId</code> case.</li>
</ul>
<p><strong>Then move the easy ones.</strong> The <em>Owned</em> 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.</p>
<p><strong>Then split the hard ones.</strong> For each <em>Split</em> file, separate the portable core from the domain wrapper. The core stays in <code>shared/</code> as a dumb primitive. The wrapper moves down to the feature that needs the domain-specific behavior:</p>
<pre><code class="language-ts">// before — one file in shared/, quietly domain-aware
// shared/hooks/useAutosave.ts
export function useAutosave(policyId: string, draft: PolicyDraft) { /* ... */ }

// after — the portable half
// shared/lib/useAutosave.ts — generic, no domain knowledge
export function useAutosave&lt;T&gt;(key: string, value: T, save: (v: T) =&gt; Promise&lt;void&gt;) { /* ... */ }

// after — the domain half, where it belongs
// features/edit-policy/model/usePolicyAutosave.ts
export function usePolicyAutosave(policyId: string, draft: PolicyDraft) {
  return useAutosave(`policy:${policyId}`, draft, savePolicyDraft);
}
</code></pre>
<p>Notice the generic <code>useAutosave</code> got <em>better</em> by being forced portable — it now takes its save function as an argument instead of hard-coding <code>savePolicyDraft</code>, which makes it genuinely reusable for the first time. Enforcing the boundary didn&#39;t just tidy the folder. It improved the primitive.</p>
<p><strong>Then delete.</strong> The satisfying part. Once the <em>Owned</em> and <em>Split</em> files are gone, you&#39;ll almost always find a handful of files nothing imports anymore — helpers that existed only to serve code that moved. Delete them. A shrinking <code>shared/</code> is the clearest signal the dig-out is working.</p>
<p>You don&#39;t have to finish in one sitting. Do a bucket a week. The direction matters more than the speed: as long as <code>shared/</code> is getting smaller and more portable over time instead of larger and vaguer, you&#39;re winning.</p>
<h2>A word on the barrel</h2>
<p>While you&#39;re in here, you&#39;ll be tempted to add an <code>index.ts</code> that re-exports everything so imports look clean: <code>import { Button, formatCurrency } from &#39;@/shared&#39;</code>. Resist making that reflexive. A barrel over a genuinely small, stable, portable <code>shared/</code> is fine. A barrel over the junk drawer just gives the junk drawer a tidy front door — and it can quietly drag your whole <code>shared/</code> 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&#39;s public surface should really be — is a whole topic on its own, and one I want to come back to.</p>
<h2>The folder was never the problem</h2>
<p>It&#39;s easy to read all this as &quot;<code>shared/</code> is bad, avoid it.&quot; That&#39;s the wrong lesson. Every non-trivial app needs a home for genuinely portable code, and <code>shared/</code> is a perfectly good name for it. The folder was never the enemy.</p>
<p>The missing promise was. A junk drawer is just a drawer that nobody agreed on the contents of. The fix isn&#39;t a better folder name or a stricter reviewer — it&#39;s making the promise explicit, and then making a machine enforce it so the promise survives the next deadline. Do that, and <code>shared/</code> 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 <code>shared/</code>, boring is the whole point.</p>
<p>Next, I want to pick up the barrel thread I brushed past earlier: <a href="https://ma-x.im/blog/react-playbook-barrel-files">why the tidy <code>index.ts</code> that gives a folder a clean front door</a> can quietly drag half your app into a bundle that only needed one function — and when it&#39;s still worth it.</p>
<p>If you&#39;ve got a <code>shared/</code> folder you&#39;re a little afraid to open — or a war story about one that got completely out of hand — I&#39;d genuinely like to hear it. Reach out; I collect these.</p>
]]></content:encoded>
      <pubDate>Tue, 16 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>FSD</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>React in 3D: When &lt;div&gt; Becomes &lt;mesh&gt;</title>
      <link>https://ma-x.im/blog/react-playbook-vr-ar</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-vr-ar</guid>
      <description>The idea that React could render a 3D scene sounds like a stretch — React is for interfaces, 3D is a different universe. Except it isn&apos;t a stretch at all, and understanding why closes out this whole series with its central idea: React was never really about the DOM. This is where that finally becomes obvious.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-vr-ar/cover.png" alt="React in 3D: When &lt;div&gt; Becomes &lt;mesh&gt;" /></p>
<p>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.</p>
<p>It isn&#39;t, and the reason it isn&#39;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: <strong>React was never really about the DOM.</strong> 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.</p>
<p>So this last article is short, and it&#39;s really about a realization more than a technology. Let me show you what I mean.</p>
<h2>The Scene Graph Is Just Another Tree</h2>
<p>Here&#39;s the conceptual key. React renders trees. The DOM is a tree of elements — a <code>div</code> containing a <code>span</code> containing text. A 3D scene is <em>also</em> 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&#39;s component model, built for exactly that shape, fits a 3D scene as naturally as it fits a webpage.</p>
<p><a href="https://r3f.docs.pmnd.rs">React Three Fiber</a> is the renderer that makes this literal. It&#39;s React — real React, same hooks, same state, same composition — where the elements are 3D objects instead of HTML tags. You don&#39;t write <code>&lt;div&gt;</code>; you write <code>&lt;mesh&gt;</code>. You don&#39;t nest <code>&lt;p&gt;</code> inside <code>&lt;section&gt;</code>; you nest a geometry and a material inside a mesh.</p>
<pre><code class="language-tsx">import { Canvas } from &#39;@react-three/fiber&#39;;

function Box() {
  return (
    &lt;mesh rotation={[0.4, 0.2, 0]}&gt;
      &lt;boxGeometry args={[1, 1, 1]} /&gt;
      &lt;meshStandardMaterial color=&quot;#22c55e&quot; /&gt;
    &lt;/mesh&gt;
  );
}

export function Scene() {
  return (
    &lt;Canvas&gt;
      &lt;ambientLight /&gt;
      &lt;pointLight position={[10, 10, 10]} /&gt;
      &lt;Box /&gt;
    &lt;/Canvas&gt;
  );
}
</code></pre>
<p>Read that and the uncanny thing is how <em>ordinary</em> it is. It&#39;s components. <code>&lt;Box /&gt;</code> is a component you could reuse, wrap in a list, conditionally render. <code>rotation</code> and <code>color</code> are just props — drive them from state and the cube animates or recolors, because it&#39;s the same reactivity you&#39;ve used all series. <code>&lt;mesh&gt;</code> is to a 3D renderer what <code>&lt;div&gt;</code> 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.</p>
<h2>VR and AR Are Just More of the Same Realization</h2>
<p>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&#39;s point of view, a 3D scene rendered in stereo with head tracking. AR is a 3D scene composited over a camera feed. The <em>scene</em> — 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.</p>
<p>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&#39;s 3D on the web. Render it to a headset in stereo — that&#39;s VR. Composite it onto the real world — that&#39;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 <a href="https://ma-x.im/blog/react-playbook-desktop">desktop window</a> to <a href="https://ma-x.im/blog/react-playbook-react-native">native phone</a>. 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.</p>
<h2>The Thing This Whole Series Was Actually About</h2>
<p>I saved this topic for last on purpose, because it makes the through-line of everything before it visible.</p>
<p>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 <em>same</em> 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&#39;s not a coincidence you stumble on at article thirty — it&#39;s the thing the whole series was quietly teaching under the cover of specific problems.</p>
<blockquote>
<p>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.</p>
</blockquote>
<p>That&#39;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&#39;s rare in software, and 3D is the clearest proof: the moment <code>&lt;div&gt;</code> becomes <code>&lt;mesh&gt;</code> and <em>nothing else about how you think has to change</em>, you understand what you actually learned. You didn&#39;t learn the DOM. You learned React.</p>
<h2>How I&#39;d Approach It — and Where the Series Lands</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>3D isn&#39;t a hack on top of React.</strong> A scene is a tree, React renders trees, and React Three Fiber makes the elements 3D objects. <code>&lt;mesh&gt;</code> is the new <code>&lt;div&gt;</code>.</li>
<li><strong>Your whole component model transfers.</strong> Props, state, hooks, composition, reuse — all unchanged. Only the leaf primitives differ.</li>
<li><strong>VR and AR are the same tree, a different output device.</strong> Screen, headset, camera-composited world. Learn the scene, not three separate stacks.</li>
<li><strong>The portable asset was never the platform.</strong> It&#39;s the model: UI as a function of state, reconciled by a renderer against a target.</li>
</ul>
<p>And that&#39;s where I&#39;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&#39;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.</p>
<p>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&#39;d genuinely like to hear about it. That&#39;s the conversation that makes writing thirty of these worth it.</p>
]]></content:encoded>
      <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>3D</category>
      <category>VR</category>
      <category>AR</category>
      <category>React Three Fiber</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>React Native: What &apos;Learn Once, Write Anywhere&apos; Really Means</title>
      <link>https://ma-x.im/blog/react-playbook-react-native</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-react-native</guid>
      <description>React Native&apos;s promise gets misremembered as &apos;write once, run everywhere&apos; — and that misremembering is why so many web developers hit a wall. The knowledge transfers; the code and the instincts often don&apos;t. Here&apos;s the honest line between what your React experience buys you on mobile and what it quietly doesn&apos;t.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-react-native/cover.png" alt="React Native: What &apos;Learn Once, Write Anywhere&apos; Really Means" /></p>
<p>The single most useful thing to get straight about React Native is a slogan, and it&#39;s one people consistently misquote. The promise was never &quot;write once, run anywhere.&quot; It was &quot;<em>learn</em> once, write anywhere&quot; — and the difference between those two words is the entire reason web developers either love React Native or feel betrayed by it.</p>
<p>Because here&#39;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 <em>not</em> transfer is a lot of your code and, more subtly, a lot of your instincts. The <code>&lt;div&gt;</code> you&#39;ve typed a hundred thousand times doesn&#39;t exist. CSS as you know it doesn&#39;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.</p>
<p>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 &quot;learn once,&quot; not &quot;write once,&quot; and don&#39;t waste a week being angry at the wall.</p>
<h2>There Is No DOM. That&#39;s the Whole Shift.</h2>
<p>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 <em>actual native platform views</em> — a real iOS <code>UIView</code>, a real Android widget. React is the engine describing the UI; the thing it draws is native, not HTML.</p>
<p>This is why the primitives change. You don&#39;t write <code>&lt;div&gt;</code> and <code>&lt;span&gt;</code> and <code>&lt;p&gt;</code>, because those are HTML and there&#39;s no HTML here. You write <code>&lt;View&gt;</code> and <code>&lt;Text&gt;</code>, which map to genuine native components:</p>
<pre><code class="language-tsx">import { View, Text, Pressable } from &#39;react-native&#39;;

export function Greeting({ name }: { name: string }) {
  return (
    &lt;View style={styles.card}&gt;
      &lt;Text style={styles.title}&gt;Hi {name}&lt;/Text&gt;
      &lt;Pressable onPress={() =&gt; console.log(&#39;tapped&#39;)}&gt;
        &lt;Text&gt;Tap me&lt;/Text&gt;
      &lt;/Pressable&gt;
    &lt;/View&gt;
  );
}
</code></pre>
<p>Notice how <em>familiar</em> the shape is — it&#39;s components, props, a style prop, an event handler. And notice how many small things are different: <code>View</code> not <code>div</code>, <code>Text</code> wrapping every piece of text (you can&#39;t just drop a bare string like in HTML), <code>onPress</code> not <code>onClick</code>, <code>Pressable</code> instead of a <code>button</code>. That&#39;s &quot;learn once, write anywhere&quot; in miniature — the <em>shape</em> of your React knowledge fits perfectly, and every <em>specific</em> has to be relearned. The engine is the same; the vocabulary is new.</p>
<h2>Styling: It Looks Like CSS and Isn&#39;t</h2>
<p>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.</p>
<p>React Native styling <em>resembles</em> 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&#39;s no <code>display: block</code> versus <code>inline</code>. Most jarring of all: <strong>everything is flexbox, and it&#39;s vertical by default.</strong> On the web, <code>flexDirection</code> defaults to <code>row</code>; in React Native it defaults to <code>column</code>, 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.</p>
<pre><code class="language-tsx">import { StyleSheet } from &#39;react-native&#39;;

const styles = StyleSheet.create({
  card: {
    // flexDirection defaults to &#39;column&#39; here — the opposite of the web.
    padding: 16,
    backgroundColor: &#39;#fff&#39;,
  },
  title: { fontSize: 18, fontWeight: &#39;600&#39; },
});
</code></pre>
<p>None of this is hard. It&#39;s just <em>different</em>, and the danger is precisely that it looks similar enough that you don&#39;t respect the difference and spend an afternoon confused why your row is a column. Expect styling to be &quot;CSS-flavored, not CSS,&quot; and the friction drops away. Treat it as the CSS you know and it will quietly punish you.</p>
<h2>Just Use Expo</h2>
<p>A practical fork, stated plainly because it saves the most pain: when you start a React Native project, use <a href="https://expo.dev">Expo</a>. 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&#39;t sign up to learn on day one.</p>
<p>Expo is to React Native roughly what <a href="https://ma-x.im/blog/react-playbook-starting-new-project">a good meta-framework is to React on the web</a>: it owns the miserable setup and configuration so you can stay in the part you&#39;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&#39;t mean touching native code. It&#39;s the same lesson this whole series keeps landing on: don&#39;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.</p>
<h2>The Instinct That Actually Needs Retraining</h2>
<p>Beyond the vocabulary, there&#39;s a deeper adjustment, and it&#39;s the one that separates a web app running on a phone from an app that belongs there. <strong>Mobile is not a small desktop, and users can feel the difference instantly.</strong></p>
<p>Touch is not a mouse. There&#39;s no hover — a whole category of web interaction just doesn&#39;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&#39;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.</p>
<p>This is the honest edge of &quot;learn once, write anywhere&quot;: your React <em>transfers</em>, but your sense of what a good interface <em>is</em> has to expand from the web&#39;s assumptions to the phone&#39;s. That&#39;s not a knock on React Native — it&#39;s the reality of the platform being genuinely native, which is the whole point of using it.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>It&#39;s &quot;learn once,&quot; not &quot;write once.&quot;</strong> Your React knowledge transfers whole; a lot of your code and instincts don&#39;t. Expect that and the wall becomes a curriculum.</li>
<li><strong>There is no DOM.</strong> <code>View</code> and <code>Text</code>, not <code>div</code> and <code>span</code>; <code>onPress</code>, not <code>onClick</code>. You render to real native views.</li>
<li><strong>Styling is CSS-flavored, not CSS.</strong> No cascade, no selectors, flexbox everywhere, and column-by-default. Respect the difference instead of trusting the resemblance.</li>
<li><strong>Use Expo.</strong> It owns the native build plumbing so you stay in React. Go bare only with a specific reason.</li>
<li><strong>Retrain your interface instincts.</strong> Touch, gestures, finger targets, platform conventions, device limits — mobile is native, not a narrow desktop.</li>
</ul>
<p>The reason React Native either delights or frustrates comes down to which promise you showed up believing. Arrive expecting &quot;write once, run everywhere&quot; and every difference feels like a broken promise. Arrive expecting &quot;learn once, write anywhere&quot; 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.</p>
<p>For the final article, the series leaves flat screens behind completely: <a href="https://ma-x.im/blog/react-playbook-vr-ar">React in VR and AR</a> — where the same component model gets pointed at three-dimensional space, and what it means to write <code>&lt;mesh&gt;</code> instead of <code>&lt;div&gt;</code>.</p>
<p>If you&#39;re a web developer eyeing your first React Native project and bracing for a total restart, don&#39;t — it&#39;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&#39;ll tell you where your web instincts will help and where they&#39;ll trip you.</p>
]]></content:encoded>
      <pubDate>Tue, 09 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>React Native</category>
      <category>Mobile</category>
      <category>Expo</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>React on the Desktop: When a Window Beats a Tab</title>
      <link>https://ma-x.im/blog/react-playbook-desktop</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-desktop</guid>
      <description>The pitch is irresistible — ship your React app as a real desktop program, same code, native icon in the dock. Electron and Tauri both promise it. But the interesting question isn&apos;t &apos;can I,&apos; it&apos;s &apos;what do I actually gain that a browser tab can&apos;t give me,&apos; and the answer decides which tool, and whether you should bother at all.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-desktop/cover.png" alt="React on the Desktop: When a Window Beats a Tab" /></p>
<p>There&#39;s a specific moment where the desktop pitch becomes irresistible. You&#39;ve built a genuinely good React app, it lives in a browser tab, and someone asks &quot;can we make it a real app?&quot; — 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.</p>
<p>So the tempting question is &quot;how do I turn my React app into a desktop app,&quot; and it&#39;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 <em>what do I gain by leaving the browser at all?</em> 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&#39;t already have. The whole justification for going desktop is the stuff a browser tab structurally <em>cannot</em> do, and if you don&#39;t need that stuff, you probably shouldn&#39;t be here.</p>
<p>This article is about that line — what the desktop actually buys you — and the honest tradeoff between the two tools that get you there.</p>
<h2>What a Browser Tab Genuinely Can&#39;t Do</h2>
<p>Start with the reason to leave the browser, because it&#39;s the whole point. A web page runs in a sandbox, deliberately walled off from the machine it&#39;s on — that&#39;s a security feature, not a limitation to complain about. But it means there are real capabilities a tab simply doesn&#39;t have, and those capabilities are your entire reason for going desktop.</p>
<p>Deep filesystem access — reading and writing arbitrary files on the user&#39;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&#39;t focused, notifications that feel like the system&#39;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&#39;t a nicety — it&#39;s the only place the feature can exist.</p>
<p>And the contrapositive matters just as much: if your app needs <em>none</em> of these — if it&#39;s fundamentally a web app that someone just wants to &quot;feel&quot; installed — then wrapping it in Electron mostly buys you weight and update headaches. Be honest about which side of that line you&#39;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.</p>
<h2>The Two Tools, and the Real Tradeoff</h2>
<p>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.</p>
<p><strong>Electron</strong> is the established one — VS Code, Slack, Discord are all Electron. Its model is straightforward: it ships an entire copy of Chromium <em>and</em> 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&#39;re shipping a browser per app, times every Electron app the user has installed.</p>
<p><strong>Tauri</strong> is the modern challenger with a different bet. Instead of bundling Chromium, it uses the operating system&#39;s <em>own</em> 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&#39;re rendering on WebKit on macOS, WebView2 on Windows — <em>different engines</em> — 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.</p>
<pre><code>                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&#39;s)
  You trade     size &amp; memory                cross-engine testing + Rust
</code></pre>
<p>So the choice isn&#39;t &quot;which is better,&quot; it&#39;s which cost you&#39;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&#39;re priced differently, and the right pick depends on whether size or familiarity hurts you more.</p>
<h2>The Part That&#39;s Actually Different: Two Processes and a Bridge</h2>
<p>Whichever you choose, there&#39;s one architectural shift that catches web developers off guard, and it&#39;s worth understanding because it shapes everything you build. A desktop app is not one environment — it&#39;s <em>two</em>, with a guarded door between them.</p>
<p>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&#39;t just call each other&#39;s functions; they communicate over an explicit bridge, passing messages across. Your React code doesn&#39;t read a file directly — it <em>asks</em> the backend to, and the backend, which actually has the permission, does it and sends the result back.</p>
<pre><code class="language-ts">// Renderer (your React UI): it cannot touch the disk itself.
// It asks the privileged backend to do it — Tauri example.
import { invoke } from &#39;@tauri-apps/api/tauri&#39;;

async function loadNote(id: string) {
  // Crosses the bridge to the Rust backend, which has real file access.
  const contents = await invoke&lt;string&gt;(&#39;read_note&#39;, { id });
  return contents;
}
</code></pre>
<p>This should feel familiar, because it&#39;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 <a href="https://ma-x.im/blog/react-playbook-authentication">client/server split in auth</a>, 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.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>Ask what you gain, not whether you can.</strong> If you don&#39;t need filesystem, OS integration, or true offline, the desktop is a heavier browser tab. Don&#39;t ship one for a fancy bookmark.</li>
<li><strong>The desktop is for touching the machine.</strong> Deep file access, tray/menus/global shortcuts, native resources, first-class offline — that&#39;s the whole reason to leave the browser.</li>
<li><strong>Electron vs Tauri is a cost choice, not a quality one.</strong> Electron: big and memory-hungry, but consistent and web-stack-familiar. Tauri: tiny, but cross-engine testing and a Rust backend.</li>
<li><strong>It&#39;s two processes with a bridge.</strong> Your React renderer asks a privileged backend to do the powerful things. Treat that bridge like a client/server boundary, because it is one.</li>
</ul>
<p>The thing I want you to take from this is that &quot;turn my React app into a desktop app&quot; 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&#39;re out of the sandbox. Go desktop when your app needs the machine. Stay in the tab when it doesn&#39;t. And when you do go, remember you didn&#39;t escape the client/server split, you just packaged both halves into one window.</p>
<p>The next article stays off the web but changes machines entirely: <a href="https://ma-x.im/blog/react-playbook-react-native">React Native and mobile</a> — where &quot;same code, new platform&quot; gets far more honest about what actually carries over and what doesn&#39;t.</p>
<p>If you&#39;re weighing a desktop build and can&#39;t tell whether you truly need to leave the browser, that&#39;s the exact question worth answering first — and it&#39;s usually clearer than it feels. Tell me what native capability you&#39;re reaching for and I&#39;ll tell you if the desktop is really the only place to get it.</p>
]]></content:encoded>
      <pubDate>Fri, 05 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Desktop</category>
      <category>Electron</category>
      <category>Tauri</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Documenting Components: The Only Docs That Don&apos;t Rot</title>
      <link>https://ma-x.im/blog/react-playbook-component-documentation</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-component-documentation</guid>
      <description>Every team writes component docs the same way — a wiki page, a screenshot, a table of props — and every team watches them go stale within a month. The problem isn&apos;t discipline. It&apos;s that the docs live in a different place than the code. The fix is docs that can&apos;t drift because they&apos;re generated from the component itself.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-component-documentation/cover.png" alt="Documenting Components: The Only Docs That Don&apos;t Rot" /></p>
<p>Every team I&#39;ve watched try to document their components starts in the same place: a page in the wiki. Someone writes up the <code>Button</code> — here are the variants, here&#39;s a screenshot, here&#39;s a table of props — and for about three weeks it&#39;s great. Then someone adds a <code>loading</code> prop and doesn&#39;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.</p>
<p>This isn&#39;t a discipline problem, and treating it as one — &quot;we just need to be better about updating the docs&quot; — is why it keeps happening. People aren&#39;t lazy; the docs are structurally doomed. The reason is simple and it&#39;s the whole point of this article: <strong>the documentation lives in a different place than the code it describes.</strong> Two sources of truth, updated by two separate acts of will, and one of them always falls behind. You&#39;ve seen this exact shape before — it&#39;s the same two-sources-of-truth failure as the <a href="https://ma-x.im/blog/react-playbook-websockets">real-time cache</a>, just applied to prose instead of state.</p>
<p>So the question isn&#39;t &quot;how do we write better component docs.&quot; It&#39;s &quot;how do we make docs that <em>can&#39;t</em> drift&quot; — and the answer is to stop writing docs that sit next to the code and start generating them from the code itself.</p>
<h2>Why the Wiki Always Loses</h2>
<p>Sit with why the wiki page decays, because the mechanism matters. When you change a component, updating its separate documentation is a <em>second, optional action</em>. 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.</p>
<p>The insight that fixes it is to remove the second action entirely. If the documentation is <em>derived from the code</em> 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&#39;s no longer a gap for drift to live in. This is the same instinct as generating types from a <a href="https://ma-x.im/blog/react-playbook-typescript-patterns">schema</a> or <a href="https://ma-x.im/blog/react-playbook-database-turso">migrations from a model</a>: one source of truth, everything else derived. Documentation is just another thing that should be derived, not duplicated.</p>
<h2>The Props Table Should Come From the Props</h2>
<p>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&#39;s props are <em>already</em> fully described — in its TypeScript types. That&#39;s a machine-readable specification of every prop, its type, whether it&#39;s required, sitting right there in the code.</p>
<pre><code class="language-tsx">interface ButtonProps {
  /** Visual style of the button. */
  variant: &#39;primary&#39; | &#39;secondary&#39; | &#39;danger&#39;;
  /** Shows a spinner and disables interaction. */
  loading?: boolean;
  /** Called when the button is clicked. */
  onClick: () =&gt; void;
}

export function Button({ variant, loading, onClick }: ButtonProps) {
  /* ... */
}
</code></pre>
<p>Everything a props table would contain is in that interface — the names, the exact allowed values of <code>variant</code>, which props are optional, and even the descriptions in the doc comments. Tooling like <a href="https://storybook.js.org">Storybook</a> reads those types directly and generates the props table for you. Add a prop, the table gains a row. Change <code>variant</code>&#39;s options, the table updates. Delete a prop, its row disappears. You didn&#39;t maintain anything — the documentation is a <em>view</em> of the types, and the types can&#39;t be wrong because they&#39;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.</p>
<h2>Living Examples Beat Screenshots</h2>
<p>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&#39;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.</p>
<p>The fix is the same move again: don&#39;t take a picture of the component, <em>render the real component</em>. A story in Storybook is a live instance of the actual component, mounted with real props, that you can interact with:</p>
<pre><code class="language-tsx">import type { Meta, StoryObj } from &#39;@storybook/react&#39;;
import { Button } from &#39;./Button&#39;;

const meta: Meta&lt;typeof Button&gt; = { component: Button };
export default meta;

export const Primary: StoryObj&lt;typeof Button&gt; = {
  args: { variant: &#39;primary&#39;, onClick: () =&gt; {} },
};

export const Loading: StoryObj&lt;typeof Button&gt; = {
  args: { variant: &#39;primary&#39;, loading: true, onClick: () =&gt; {} },
};
</code></pre>
<p>Those aren&#39;t images of a button — they&#39;re the button, running, with the exact props your app would pass. If the button changes, the stories change with it, because they <em>are</em> 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 <em>is</em> right now, and stays honest by construction.</p>
<p>And there&#39;s a compounding benefit that ties back to the <a href="https://ma-x.im/blog/react-playbook-design-prototyping">design conversation</a>: a living component catalog is where designers and engineers actually agree on what exists. When the real <code>Button</code>, in all its states, is browsable in one place, &quot;does this already exist?&quot; 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.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>Separate docs always rot.</strong> It&#39;s not a discipline problem — it&#39;s two sources of truth. Stop maintaining docs next to the code.</li>
<li><strong>Derive, don&#39;t duplicate.</strong> Generate documentation from the code so changing the code changes the docs, automatically.</li>
<li><strong>The props table comes from the types.</strong> Your TypeScript interface already is the spec; let tooling render it. Never hand-write a props table.</li>
<li><strong>Render real components, not screenshots.</strong> A live story stays accurate and shows interactive states; an image is stale the moment you save it.</li>
<li><strong>A living catalog drives reuse.</strong> When every component is browsable in all its states, &quot;does this exist?&quot; becomes answerable — and things get reused instead of rebuilt.</li>
</ul>
<p>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 <em>write</em> and start treating it as a thing you <em>derive</em> — from the types, from the real components, from the code that&#39;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&#39;s the only kind of documentation I&#39;ve seen survive contact with a deadline.</p>
<p>This is where the &quot;build a real app&quot; 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, <a href="https://ma-x.im/blog/react-playbook-desktop">React on the desktop</a> — when Electron or Tauri actually earns its place, and what changes when your React app is a window instead of a tab.</p>
<p>If your team has component docs that nobody trusts, it&#39;s almost certainly the separate-source-of-truth problem, and it&#39;s fixable without a documentation crusade. Tell me how your docs are set up and I&#39;ll tell you which piece to derive instead of write.</p>
]]></content:encoded>
      <pubDate>Tue, 02 Jun 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Documentation</category>
      <category>Storybook</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Design Prototyping for React: Where the Architecture Actually Starts</title>
      <link>https://ma-x.im/blog/react-playbook-design-prototyping</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-design-prototyping</guid>
      <description>Most engineers treat the Figma file as a picture to reproduce. That framing is why so many handoffs go badly. A good design isn&apos;t a picture — it&apos;s the first draft of your component architecture, and the decisions that decide whether your codebase stays sane are made before a single line of React is written.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-design-prototyping/cover.png" alt="Design Prototyping for React: Where the Architecture Actually Starts" /></p>
<p>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.</p>
<p>I want to push on that framing, because I think it&#39;s the root of a lot of avoidable pain. A design isn&#39;t a picture you copy. It&#39;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 — <em>is</em> the structure of the code you&#39;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&#39;s structure and the code&#39;s structure disagree, you get the codebases everyone recognizes: technically matching the mockup, quietly miserable to maintain.</p>
<p>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.</p>
<h2>A Design Has Structure, Not Just Appearance</h2>
<p>Look at any real screen and there are two different things present at once. There&#39;s the <em>appearance</em> — the colors, the fonts, the shadows, the exact pixels. And there&#39;s the <em>structure</em> — 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.</p>
<p>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&#39;t &quot;six cards&quot; — it&#39;s &quot;one <code>Card</code> component, rendered six times.&quot; When you see a button here that&#39;s clearly the same button as three other screens, the fact is &quot;this is one shared <code>Button</code>, not a thing I restyle locally each time.&quot; 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 <a href="https://ma-x.im/blog/react-playbook-code-structure">component boundaries</a> for free.</p>
<blockquote>
<p>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.</p>
</blockquote>
<h2>Same Component, Different States — the Thing Static Mockups Hide</h2>
<p>Here&#39;s where the &quot;design is a picture&quot; framing does the most damage, and it&#39;s the single most valuable thing to internalize about prototyping for React.</p>
<p>A static mockup shows you one moment. But a React component is never one moment — it&#39;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&#39;ll ship a component that looks perfect in the demo and breaks the first time real data is empty or slow.</p>
<p>This is why the most useful thing you can do with a design <em>isn&#39;t</em> to start coding it — it&#39;s to interrogate it. Before writing the component, I go looking for the states the mockup didn&#39;t show. What does this look like with no data? While it&#39;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 &quot;the designer didn&#39;t think about that yet,&quot; and surfacing it <em>before</em> 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.</p>
<h2>This Is a Conversation, Not a Handoff</h2>
<p>That interrogation is exactly why I don&#39;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 <em>together</em>, and answering them early is where the leverage is.</p>
<p>When engineering engages with design <em>during</em> prototyping instead of after, a few things change. Reusable pieces get spotted before they&#39;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 <a href="https://ma-x.im/blog/building-design-systems-that-scale">design systems</a> 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 &quot;how do I build this&quot; is &quot;you mostly already did.&quot; The handoff model forecloses all of that by making the conversation happen too late, after the structural decisions have already hardened on both sides.</p>
<p>I&#39;m not arguing engineers should design. I&#39;m arguing that the boundary between &quot;design work&quot; and &quot;architecture work&quot; is far blurrier than the org chart suggests, and pretending it&#39;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.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>Read a design for structure, not just appearance.</strong> What repeats, what nests, what varies — that&#39;s your component tree, handed to you.</li>
<li><strong>A static mockup is one state.</strong> Before coding, hunt for the ones it hides: empty, loading, error, overflow, huge data. Build all the realities, not the one you were shown.</li>
<li><strong>Interrogate before you implement.</strong> The cheapest place to discover a missing state is a design review, not QA.</li>
<li><strong>Treat it as a conversation, not a handoff.</strong> Engaging during prototyping surfaces reuse and missing states while they&#39;re still cheap to fix.</li>
<li><strong>Prototype from your existing components.</strong> A design aligned to the system you already have ships far faster than one drawn from scratch.</li>
</ul>
<p>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&#39;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.</p>
<p>Next, the flip side of building components well: <a href="https://ma-x.im/blog/react-playbook-component-documentation">documenting them</a> — so the reusable pieces you and your designers just agreed on actually get reused instead of quietly rebuilt for the fifth time.</p>
<p>If your team&#39;s design-to-code process feels like reproducing pictures and constantly rediscovering missing states in QA, that&#39;s the handoff model doing its usual damage. Tell me where it hurts — the reuse, the missing states, the drift — and I&#39;ll tell you where the conversation needs to move earlier.</p>
]]></content:encoded>
      <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Design</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Sending Email from a React App: A Backend Problem in Disguise</title>
      <link>https://ma-x.im/blog/react-playbook-mails</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-mails</guid>
      <description>&quot;Just send them an email&quot; is one of those sentences that sounds like a five-minute task and turns into a week. The React part is trivial. The hard part is that email is a deceptively deep backend system with deliverability, templates, and reputation — and none of it belongs in your frontend. Here&apos;s where the real work actually is.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-mails/cover.png" alt="Sending Email from a React App: A Backend Problem in Disguise" /></p>
<p>&quot;When someone signs up, just send them a welcome email.&quot; It&#39;s the kind of request that gets tossed into a standup like it&#39;s nothing, and the person saying it genuinely believes it&#39;s a five-minute task. Send an email. How hard could it be?</p>
<p>Here&#39;s the thing that makes email sneaky: the part you&#39;d expect to be hard is trivial, and the part you&#39;d expect to be trivial is where the whole thing lives. Triggering an email from your app is a single function call. <em>Actually getting that email into someone&#39;s inbox</em> — 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.</p>
<p>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.</p>
<h2>The Line: The Client Never Sends Email</h2>
<p>Start with the hard boundary, because it&#39;s the one people are tempted to cross. <strong>Your React app does not send email. Ever.</strong> It triggers a request to your backend, and your backend sends the email.</p>
<p>This isn&#39;t a style preference, it&#39;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&#39;ve handed the entire internet the ability to send email <em>as you</em>, which means spammers now have a free megaphone wearing your domain&#39;s reputation. Within days your domain is blacklisted and even your legitimate mail stops arriving. So the flow is always the same shape as <a href="https://ma-x.im/blog/react-playbook-payments">payments</a> and <a href="https://ma-x.im/blog/react-playbook-authentication">auth</a>: the client asks, the server acts, and the credential never leaves the server.</p>
<pre><code class="language-ts">// The client&#39;s entire involvement: ask the backend to do it.
async function requestWelcomeEmail(userId: string) {
  await fetch(&#39;/api/emails/welcome&#39;, {
    method: &#39;POST&#39;,
    body: JSON.stringify({ userId }),
  });
}
</code></pre>
<p>That&#39;s the whole frontend story. There is no <code>sendEmail</code> 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&#39;ve done it wrong. Everything interesting from here happens on the server.</p>
<h2>Why You Don&#39;t Talk to SMTP Yourself</h2>
<p>On the backend, the naive mental model is that you connect to a mail server over SMTP and send. You <em>can</em> do that. You shouldn&#39;t, and understanding why is the core of this whole topic.</p>
<p>Getting an email delivered is not about transmitting it — it&#39;s about being <em>trusted</em> 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&#39;re allowed to send as your domain; your sending IP needs a <em>reputation</em> 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.</p>
<p>This is why transactional email services — <a href="https://resend.com">Resend</a>, Postmark, SendGrid — exist, and why using one isn&#39;t laziness but the correct architecture. They&#39;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 <em>actually getting delivered</em> is theirs.</p>
<pre><code class="language-ts">// Your server. The service owns deliverability; you own intent.
import { Resend } from &#39;resend&#39;;
const resend = new Resend(process.env.RESEND_API_KEY); // server-only secret

app.post(&#39;/api/emails/welcome&#39;, async (req, res) =&gt; {
  const user = await getUser(req.body.userId);
  await resend.emails.send({
    from: &#39;hello@yourdomain.com&#39;,
    to: user.email,
    subject: &#39;Welcome aboard&#39;,
    react: &lt;WelcomeEmail name={user.name} /&gt;, // more on this below
  });
  res.json({ queued: true });
});
</code></pre>
<p>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.</p>
<h2>The Genuinely Weird Part: Email Rendering Is Stuck in 2005</h2>
<p>Here&#39;s the one place your React skills partly transfer, and it comes with a nasty surprise. You&#39;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.</p>
<p>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 <em>tables</em>, the way the web worked in 2005, with inlined styles because <code>&lt;style&gt;</code> blocks get stripped. Hand-writing that is miserable and easy to get wrong across the dozens of clients your users actually use.</p>
<p>The modern rescue is that you <em>can</em> write email templates as React components — using a library like <a href="https://react.email">React Email</a> — 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.</p>
<pre><code class="language-tsx">import { Html, Button, Text } from &#39;@react-email/components&#39;;

export function WelcomeEmail({ name }: { name: string }) {
  return (
    &lt;Html&gt;
      &lt;Text&gt;Hi {name}, glad you&#39;re here.&lt;/Text&gt;
      &lt;Button href=&quot;https://yourdomain.com/start&quot;&gt;Get started&lt;/Button&gt;
    &lt;/Html&gt;
  );
}
</code></pre>
<p>That looks like a normal component, but <code>&lt;Button&gt;</code> and <code>&lt;Text&gt;</code> aren&#39;t rendering <code>&lt;button&gt;</code> and <code>&lt;p&gt;</code> — they emit the gnarly, table-wrapped, inline-styled markup that renders consistently from Gmail to a decade-old Outlook. It&#39;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.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>The client never sends email.</strong> It asks the backend. The API key is a server-only secret — leaking it burns your domain&#39;s reputation.</li>
<li><strong>Don&#39;t run your own mail server.</strong> Deliverability is a reputation-and-authentication system, not a transmission problem. Use a transactional email service.</li>
<li><strong>You&#39;re buying deliverability, not a send button.</strong> SPF/DKIM/DMARC, bounce handling, and IP reputation are the actual product these services sell.</li>
<li><strong>Email rendering is stuck in the past.</strong> Write templates as React components via React Email; let it compile down to the table-based HTML that survives Outlook.</li>
</ul>
<p>The reason &quot;just send an email&quot; 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%.</p>
<p>Next, the series shifts from wiring things up to thinking about them differently: <a href="https://ma-x.im/blog/react-playbook-design-prototyping">design prototyping for React</a> — where design work stops being visual and starts being architectural, and why that handoff matters more than people admit.</p>
<p>If you&#39;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&#39;s biting you and I&#39;ll point you at the layer to check.</p>
]]></content:encoded>
      <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Email</category>
      <category>Backend</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>File Upload in React: The Input Is Easy, the File Is Not</title>
      <link>https://ma-x.im/blog/react-playbook-file-upload</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-file-upload</guid>
      <description>An upload looks like a one-liner — a file input, a POST, done. Then someone uploads a two-gigabyte video on hotel wifi and the whole illusion collapses. The hard part was never the input. It&apos;s moving a large, unreliable blob of bytes from a browser to somewhere it can live. Here&apos;s how I actually handle it.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-file-upload/cover.png" alt="File Upload in React: The Input Is Easy, the File Is Not" /></p>
<p>Adding a file upload feels like a solved problem. You drop an <code>&lt;input type=&quot;file&quot;&gt;</code> on the page, grab the file in an <code>onChange</code>, 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.</p>
<p>Then it meets reality. Someone uploads a two-gigabyte video from hotel wifi. Someone&#39;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 <em>large, unreliable stream of bytes</em> 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.</p>
<p>So this article isn&#39;t about the input element. It&#39;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&#39;s happening.</p>
<h2>The First Decision: Your Server Shouldn&#39;t Touch the File</h2>
<p>Here&#39;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&#39;s the obvious shape, and it&#39;s the wrong one for anything but tiny files.</p>
<p>Why it&#39;s wrong: your server now has to <em>hold</em> 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&#39;t yours. You&#39;ve turned your application server into a file relay, and file relays are exactly what dedicated storage services already are, better than you.</p>
<p>The pattern that fixes it is <strong>presigned uploads</strong>, and it&#39;s worth understanding because it reshapes the whole flow. The file goes browser → storage <em>directly</em>, and your server&#39;s only job is to hand out permission. The client asks your API &quot;I want to upload this file,&quot; 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.</p>
<pre><code class="language-ts">// Your server: it never sees the file. It only grants permission.
app.post(&#39;/api/uploads/sign&#39;, async (req, res) =&gt; {
  const { filename, contentType } = req.body;

  // Ask storage for a short-lived URL scoped to this one upload.
  const uploadUrl = await storage.createPresignedUrl({
    key: `uploads/${crypto.randomUUID()}/${filename}`,
    contentType,
    expiresIn: 60, // seconds — the URL is useless after that
  });

  res.json({ uploadUrl });
});
</code></pre>
<pre><code class="language-ts">// The browser: uploads the bytes directly to storage, not to you.
async function uploadFile(file: File) {
  const { uploadUrl } = await fetch(&#39;/api/uploads/sign&#39;, {
    method: &#39;POST&#39;,
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  }).then((r) =&gt; r.json());

  await fetch(uploadUrl, { method: &#39;PUT&#39;, body: file }); // straight to storage
}
</code></pre>
<p>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 <a href="https://ma-x.im/blog/react-playbook-authentication">server-is-the-authority, client-does-the-work</a> split from the auth and payments articles: your backend controls <em>permission</em>, but it doesn&#39;t do the heavy lifting it has no business doing.</p>
<h2>The Second Reality: The Upload Is a Process, Not a Request</h2>
<p>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&#39;s a process that <em>unfolds over time</em>, and your UI has to treat it that way.</p>
<p>A 40KB avatar uploads instantly, so you can pretend it&#39;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&#39;s working. If you show them a frozen spinner, they&#39;ll assume it hung and refresh — killing the upload. So progress isn&#39;t a nice-to-have here; it&#39;s the difference between a feature that works and one users abort halfway. You have to report how far along the bytes are:</p>
<pre><code class="language-ts">function uploadWithProgress(url: string, file: File, onProgress: (pct: number) =&gt; void) {
  const xhr = new XMLHttpRequest();
  xhr.upload.addEventListener(&#39;progress&#39;, (e) =&gt; {
    if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
  });
  xhr.open(&#39;PUT&#39;, url);
  xhr.send(file);
}
</code></pre>
<p>That <code>xhr.upload.progress</code> event — one of the few times raw <code>XMLHttpRequest</code> still earns its place over <code>fetch</code>, which can&#39;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&#39;t. The technical work of measuring progress is small; the UX consequence of showing it is large.</p>
<h2>The Third Reality: Big Files Need to Survive a Dropped Connection</h2>
<p>Now the hardest layer, and the one that separates a toy from a product: what happens at 90% when the wifi blinks?</p>
<p>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, &quot;start over from zero&quot; may mean it <em>never</em> 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&#39;t finish.</p>
<p>The answer is <strong>chunked (multipart) uploads</strong>: 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.</p>
<p>I want to be honest about the tradeoff, because this is where the &quot;it&#39;s just an input&quot; fantasy fully dies: chunking is genuinely more complex. You&#39;re tracking which chunks succeeded, retrying the ones that didn&#39;t, handling out-of-order completion, and telling storage to stitch them back together. This is real coordination logic, and it&#39;s exactly why you don&#39;t hand-roll it. Storage services expose multipart upload APIs, and libraries like <a href="https://uppy.io">Uppy</a> 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.</p>
<h2>Don&#39;t Forget: The File Is Untrusted</h2>
<p>One security note that&#39;s too important to skip, because uploads are a classic attack surface. A file arriving from a browser is <em>user input</em>, and the same rule from the <a href="https://ma-x.im/blog/react-playbook-payments">payments</a> and <a href="https://ma-x.im/blog/react-playbook-authentication">auth</a> articles applies: never trust it.</p>
<p>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 &quot;that&#39;s too big&quot; before they wait); server-side checks are for safety. You need both, and you must never mistake the first for the second.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>Your server hands out permission, not bytes.</strong> Use presigned URLs so the file goes browser → storage directly. Don&#39;t turn your API into a file relay.</li>
<li><strong>Treat the upload as a process.</strong> Show real progress from <code>xhr.upload</code>. A moving bar is why users wait instead of refreshing.</li>
<li><strong>Chunk large files.</strong> Multipart uploads make transfers resumable and remove the size ceiling. Don&#39;t hand-roll it — use the storage API via a library.</li>
<li><strong>The file is untrusted input.</strong> Validate size and type on the server boundary, not just in the browser. Client checks are UX; server checks are security.</li>
</ul>
<p>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&#39;re moving a large, untrusted, interruptible thing across a connection you don&#39;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.</p>
<p>Next, another piece of infrastructure that looks trivial and isn&#39;t: <a href="https://ma-x.im/blog/react-playbook-mails">sending email from a React app</a> — why &quot;just send an email&quot; is a backend problem wearing a frontend costume, and where the real work hides.</p>
<p>If you&#39;ve got an upload that works for small files and dies on big ones, that&#39;s the single-request ceiling, and it&#39;s the most common upload bug there is. Tell me where it breaks — the server memory, the timeout, the 90% drop — and I&#39;ll tell you which layer is missing.</p>
]]></content:encoded>
      <pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>File Upload</category>
      <category>Storage</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Time in React: The Bugs That Only Show Up at 2 AM</title>
      <link>https://ma-x.im/blog/react-playbook-time</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-time</guid>
      <description>Dates look like the most boring data type in your app. They are also the source of the subtlest, most embarrassing bugs — the meeting that shows an hour off, the &apos;today&apos; that&apos;s tomorrow in Tokyo, the date that shifts a day every time you save it. Here&apos;s how I stop time from quietly breaking a React app.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-time/cover.png" alt="Time in React: The Bugs That Only Show Up at 2 AM" /></p>
<p>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 &quot;created today&quot; 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.</p>
<p>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 &quot;midnight&quot; 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&#39;s name feel personal.</p>
<p>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.</p>
<h2>The One Rule That Prevents Most of It</h2>
<p>If you take nothing else from this: <strong>store and transmit time in UTC. Convert to the user&#39;s local timezone only for display.</strong> That&#39;s it. That single discipline, applied without exception, eliminates the majority of time bugs before they can form.</p>
<blockquote>
<p>A timestamp is a fact — the exact instant something happened. A timezone is a lens for <em>viewing</em> that fact. Store the fact once, in UTC. Choose the lens at the last possible moment, at render.</p>
</blockquote>
<p>Here&#39;s why this works. An instant in time is absolute — the moment a user clicked &quot;submit&quot; is the same moment everywhere on Earth. What differs is how you <em>describe</em> 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 &quot;the same moment&quot; 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&#39;s local timezone when you&#39;re about to show it to that specific human. Timezone becomes a <em>display</em> concern, which is the only place it belongs.</p>
<p>The mirror-image mistake — the one that produces the &quot;date shifts by a day every save&quot; bug — is round-tripping a date through local time on the way in <em>and</em> 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&#39;t happen.</p>
<h2>Why <code>new Date()</code> Keeps Betraying You</h2>
<p>The native <code>Date</code> object is where good intentions go to die, and it&#39;s worth being specific about why, because the failure is silent.</p>
<p><code>Date</code> is implicitly local. The moment you parse or construct one, it quietly adopts the runtime&#39;s timezone — which is your laptop in development and the user&#39;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&#39;t see it, because your machine only ever shows you your own timezone&#39;s answer.</p>
<pre><code class="language-ts">// Looks innocent. Behaves differently depending on where it runs.
const d = new Date(&#39;2026-07-21&#39;); // midnight UTC... interpreted as LOCAL
d.getDate(); // 21 in London. 20 in New York. Same code, different day.
</code></pre>
<p>That <code>getDate()</code> returning 20 for a New York user is the entire &quot;off by one day&quot; bug in miniature — the string is midnight UTC, but <code>Date</code> shows it through local eyes, and New York&#39;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.</p>
<h2>Reach for a Real Library — and Watch Temporal</h2>
<p>Because <code>Date</code> is a minefield, the long-standing answer is a library that makes timezone handling explicit instead of implicit. <a href="https://date-fns.org">date-fns</a> is my usual pick — modular, tree-shakeable, and its companion <code>date-fns-tz</code> makes conversions something you <em>state</em> rather than something that happens to you:</p>
<pre><code class="language-ts">import { formatInTimeZone } from &#39;date-fns-tz&#39;;

// The instant is stored in UTC. Display it in the user&#39;s zone, explicitly.
const utcInstant = &#39;2026-07-21T23:30:00Z&#39;;
formatInTimeZone(utcInstant, &#39;America/New_York&#39;, &#39;yyyy-MM-dd HH:mm&#39;); // &quot;2026-07-21 19:30&quot;
formatInTimeZone(utcInstant, &#39;Asia/Tokyo&#39;, &#39;yyyy-MM-dd HH:mm&#39;);      // &quot;2026-07-22 08:30&quot;
</code></pre>
<p>Notice the same UTC instant renders as the 21st in New York and the 22nd in Tokyo — correctly, because the conversion is <em>explicit</em> and timezone-aware, not left to whatever <code>Date</code> guesses. You&#39;re stating the lens instead of inheriting it.</p>
<p>The genuinely good news is that the language itself is fixing this. <strong>Temporal</strong> — the new built-in date/time API — is designed specifically to end the <code>Date</code> era: immutable objects, explicit timezone handling, no more implicit-local footguns. It&#39;s landing in engines now, and it&#39;s the direction everything moves. If you&#39;re starting fresh, it&#39;s worth watching closely and reaching for as it stabilizes; it makes the &quot;be explicit about timezones&quot; discipline the <em>default</em> instead of something you bolt on with a library.</p>
<h2>The Traps That Remain</h2>
<p>Even with the UTC rule and a real library, a few sharp edges stay sharp, and they&#39;re worth naming because each has bitten real apps.</p>
<p>Daylight saving time means some days don&#39;t have 24 hours and some local times don&#39;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 <code>24 * 60 * 60 * 1000</code> milliseconds and calling it &quot;a day&quot; is wrong twice a year; a real library that adds <em>calendar</em> days handles it, hand-rolled arithmetic does not.</p>
<p>&quot;Midnight&quot; and &quot;today&quot; are not absolute either — they&#39;re timezone-bound. &quot;Show me everything from today&quot; means a different range of instants for a user in Los Angeles than one in Berlin, and if your backend computes &quot;today&quot; in the server&#39;s timezone, half your users see the wrong day&#39;s data. &quot;Today&quot; is always <em>someone&#39;s</em> today, and you have to decide whose.</p>
<p>And serialization is where discipline leaks: the safe way to put a date on the wire is ISO 8601 in UTC (that trailing <code>Z</code>), because it&#39;s unambiguous. A date formatted for humans — or worse, a bare <code>new Date().toString()</code> — 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.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>Store and send UTC. Convert only for display.</strong> The single rule that prevents most time bugs.</li>
<li><strong>Distrust <code>new Date()</code>.</strong> It&#39;s implicitly local, so it&#39;s implicitly wrong for someone. Don&#39;t do timezone logic with it.</li>
<li><strong>Use a real library</strong> (date-fns today) and make conversions explicit. <strong>Watch Temporal</strong> and adopt it as it lands.</li>
<li><strong>Respect DST and &quot;today.&quot;</strong> A day isn&#39;t always 24 hours, and &quot;today&quot; is always somebody&#39;s local today.</li>
<li><strong>Serialize as ISO 8601 UTC.</strong> Unambiguous on the wire; humanize only at the edge.</li>
</ul>
<p>The reason time bugs are so embarrassing is that they&#39;re invisible right up until they&#39;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 &quot;today&quot; 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.</p>
<p>Next, a different kind of unglamorous-but-treacherous: <a href="https://ma-x.im/blog/react-playbook-file-upload">file uploads in React</a> — progress, chunking, and the surprisingly deep problem of moving a large file from a browser to somewhere it can live.</p>
<p>If you&#39;ve got a date that shifts by a day, a badge that says &quot;today&quot; when it isn&#39;t, or a meeting an hour off for some users, that&#39;s almost always a UTC-vs-local leak. Tell me where the wrong value shows up and I&#39;ll tell you which conversion is doing it.</p>
]]></content:encoded>
      <pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Dates</category>
      <category>Time</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Payments in React: The Client Never Decides You Got Paid</title>
      <link>https://ma-x.im/blog/react-playbook-payments</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-payments</guid>
      <description>Taking a payment looks like a form submission and is nothing like one. The card number is radioactive, the confirmation is asynchronous, and the one thing your React app must never do is believe it got paid because a callback said so. Here&apos;s where the real line sits between the browser, your server, and Stripe.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-payments/cover.png" alt="Payments in React: The Client Never Decides You Got Paid" /></p>
<p>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&#39;ve built a hundred forms. This is just a form where one of the fields happens to be a card number.</p>
<p>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&#39;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 &quot;success message&quot; hides the second trap: unlike every other form you&#39;ve built, you don&#39;t actually know the outcome when the user clicks submit. The payment confirms <em>asynchronously</em>, somewhere you can&#39;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.</p>
<p>So this article isn&#39;t a Stripe tutorial. It&#39;s about the two lines that matter — the one that keeps the card number away from you, and the one that decides where &quot;you got paid&quot; 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.</p>
<h2>The First Line: Never Touch the Card Number</h2>
<p>Start with the rule that shapes everything else: <strong>the raw card number must never reach your server.</strong> Not to &quot;just pass it through,&quot; not to log it, not for a millisecond. The instant it does, you&#39;re in PCI-compliance scope, and that is a burden measured in audits and dread, not code.</p>
<p>The whole architecture of a modern payments provider exists to keep you out of that scope, and the mechanism is worth understanding because it&#39;s genuinely clever. You don&#39;t build the card input. Stripe does — via <a href="https://stripe.com/docs/payments/elements">Stripe Elements</a>, an iframe-based field that Stripe hosts and controls. The card number is typed into <em>their</em> iframe, on <em>your</em> page, and it goes straight to Stripe. Your JavaScript can&#39;t read it. Your server never sees it. What you get back instead is a token — a harmless reference that means &quot;Stripe is holding a card for you&quot; — and <em>that</em> is what flows through your system.</p>
<pre><code class="language-tsx">import { PaymentElement, useStripe, useElements } from &#39;@stripe/react-stripe-js&#39;;

export function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!stripe || !elements) return;

    // Stripe collects the card in its own iframe and confirms directly.
    // The card number never touches your JS or your server.
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: &#39;https://example.com/order/complete&#39; },
    });

    if (error) {
      // Show the user the reason. This is the &quot;did it start&quot; answer,
      // NOT the &quot;did it succeed&quot; answer.
    }
  }

  return (
    &lt;form onSubmit={handleSubmit}&gt;
      &lt;PaymentElement /&gt;
      &lt;button type=&quot;submit&quot;&gt;Pay&lt;/button&gt;
    &lt;/form&gt;
  );
}
</code></pre>
<p>Look at what your code never handles: the card number itself. <code>&lt;PaymentElement /&gt;</code> is Stripe&#39;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 <a href="https://ma-x.im/blog/react-playbook-authentication">buy-don&#39;t-build</a> principle from the auth article at its most extreme — the risk here is so high that building it yourself isn&#39;t a trade-off, it&#39;s a mistake.</p>
<h2>The Second Line: The Client Doesn&#39;t Decide</h2>
<p>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 <em>succeeded</em>.</p>
<p>Here&#39;s why this bites people. The naive flow: user pays, Stripe&#39;s client call resolves, you show &quot;Payment successful!&quot; and mark the order as paid in your UI. It demos perfectly. And it&#39;s exploitable and unreliable, because the client is an environment you don&#39;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 &quot;mark as paid&quot; endpoint without paying at all, because anything the client asserts, a client can fake.</p>
<p>The answer is the one that runs through this whole series: <strong>the client is untrusted; the server is the source of truth.</strong> The real confirmation doesn&#39;t come from your React callback. It comes from Stripe, server-to-server, via a <strong>webhook</strong> — Stripe calls <em>your backend</em> to say &quot;this payment actually succeeded,&quot; and that server-side event is the only thing allowed to mark an order as paid.</p>
<pre><code class="language-ts">// On your server — the ONLY place an order becomes &quot;paid&quot;.
app.post(&#39;/webhooks/stripe&#39;, async (req, res) =&gt; {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers[&#39;stripe-signature&#39;],
    WEBHOOK_SECRET, // proves the call really came from Stripe
  );

  if (event.type === &#39;payment_intent.succeeded&#39;) {
    const intent = event.data.object;
    await markOrderPaid(intent.metadata.orderId); // trusted truth
  }

  res.json({ received: true });
});
</code></pre>
<p>So the client&#39;s job and the server&#39;s job split cleanly. The client <em>starts</em> the payment and shows a hopeful &quot;we&#39;re processing your order.&quot; The server, via the webhook, <em>confirms</em> it and flips the order to paid. Those are two different facts arriving through two different channels, and conflating them — treating the client&#39;s optimistic result as the settled truth — is the bug that gets apps double-charging, under-charging, or handing out product for free.</p>
<blockquote>
<p>The client can tell you a payment <em>started</em>. Only the server, via the provider&#39;s webhook, can tell you it <em>succeeded</em>. Never let the browser be the thing that says &quot;paid.&quot;</p>
</blockquote>
<p>This maps exactly onto the <a href="https://ma-x.im/blog/react-playbook-authentication">server-state idea from the auth article</a>: 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.</p>
<h2>So How Hard Is It, Really?</h2>
<p>Honestly? Less than the fear suggests, <em>if</em> you respect the two lines. Stripe has done the genuinely hard parts — the card handling, the PCI scope, the fraud tooling. What&#39;s left for you is smaller than people expect, but it&#39;s unforgiving about correctness.</p>
<p>Your real work is three things. Render Stripe&#39;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 &quot;started,&quot; not &quot;done.&quot; 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&#39;s the shape of it. It&#39;s not a huge amount of code. It&#39;s a small amount of code where being casually wrong costs actual money, which is a very different kind of hard than &quot;lots of code.&quot;</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>The card number is radioactive.</strong> Use Stripe Elements so it never touches your JS or server. Don&#39;t negotiate with PCI scope — avoid it.</li>
<li><strong>The client starts the payment; it does not confirm it.</strong> Show &quot;processing,&quot; never &quot;paid,&quot; from a client callback.</li>
<li><strong>The webhook is the source of truth.</strong> An order becomes paid on your server, when Stripe tells your server it succeeded — and never before.</li>
<li><strong>Verify the webhook signature.</strong> Otherwise anyone can call your endpoint and claim they paid.</li>
<li><strong>Buy, don&#39;t build.</strong> This is the domain where rolling your own is not clever, it&#39;s negligent.</li>
</ul>
<p>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 <em>records</em> success is a liability. Once you internalize that the browser can only ever say &quot;I tried&quot; and the server is the only thing that can say &quot;it worked,&quot; the whole domain stops being frightening and becomes just careful — two clean lines, respected every time.</p>
<p>The next article stays with the values that are deceptively easy to get wrong and quietly ruin things: <a href="https://ma-x.im/blog/react-playbook-time">time and dates in React</a> — timezones, storage, and the bugs that only surface at the worst possible hour.</p>
<p>If you&#39;ve got a checkout that marks orders paid from a client callback, that&#39;s the second line crossed, and it&#39;s worth fixing before it costs you. Tell me how your success path works and I&#39;ll tell you where the hole is.</p>
]]></content:encoded>
      <pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Payments</category>
      <category>Stripe</category>
      <category>Security</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Internationalization Isn&apos;t Translation: What Actually Breaks</title>
      <link>https://ma-x.im/blog/react-playbook-i18n</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-i18n</guid>
      <description>Everyone thinks i18n means swapping English strings for a dictionary lookup. That&apos;s the 10% that&apos;s easy. The 90% is formatting, pluralization, text that changes direction, and language data you can&apos;t afford to ship all at once. Here&apos;s what internationalizing a React app actually involves once you get past the word &apos;translation&apos;.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-i18n/cover.png" alt="Internationalization Isn&apos;t Translation: What Actually Breaks" /></p>
<p>Ask most developers what internationalization means and you&#39;ll get the same answer: instead of writing &quot;Save&quot; in your JSX, you look up a key in a dictionary and get &quot;Save&quot; or &quot;Enregistrer&quot; depending on the user&#39;s language. And they&#39;re right — that <em>is</em> part of it. It&#39;s also the easy 10%, and it&#39;s the part that makes people underestimate the whole thing.</p>
<p>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 <code>7/18/2026</code> to an American and <code>18/07/2026</code> to nearly everyone else. A price needs a different currency symbol, in a different position, with a comma where you had a period. &quot;1 item&quot; versus &quot;5 items&quot; 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.</p>
<p>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 <em>formatting and loading</em>, which is what it really is.</p>
<h2>The Reframe: It&#39;s Formatting, Not Words</h2>
<p>Here&#39;s the shift that made i18n click for me. Stop picturing a dictionary. Picture a formatting engine.</p>
<p>The hardest parts of internationalization aren&#39;t the strings you wrote — they&#39;re the <em>values</em> 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 &quot;item&quot; and &quot;items&quot; is a value running through a locale&#39;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.</p>
<blockquote>
<p>Translation handles the words you wrote. Internationalization handles the values you render. The second one is the hard part, and it&#39;s the part people skip.</p>
</blockquote>
<p>The good news is you don&#39;t implement any of this yourself. The browser ships a full formatting engine — the <code>Intl</code> API — and the React libraries are wrappers that make it ergonomic.</p>
<h2>Formatting Values the Right Way</h2>
<p>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 <em>format itself</em> is locale-dependent, not just the numbers inside it.</p>
<p>The <code>Intl</code> API already knows every locale&#39;s rules. You give it a value and a locale; it gives you correct output:</p>
<pre><code class="language-ts">// Dates: same instant, formatted for the user&#39;s locale.
new Intl.DateTimeFormat(&#39;en-US&#39;).format(date); // &quot;7/18/2026&quot;
new Intl.DateTimeFormat(&#39;de-DE&#39;).format(date); // &quot;18.7.2026&quot;

// Currency: symbol, position, and separators all differ by locale.
new Intl.NumberFormat(&#39;en-US&#39;, { style: &#39;currency&#39;, currency: &#39;USD&#39; })
  .format(1234.5); // &quot;$1,234.50&quot;
new Intl.NumberFormat(&#39;de-DE&#39;, { style: &#39;currency&#39;, currency: &#39;EUR&#39; })
  .format(1234.5); // &quot;1.234,50 €&quot;
</code></pre>
<p>Look at what you&#39;re <em>not</em> 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&#39;t just more code — it&#39;s code that&#39;s confidently wrong in locales you don&#39;t personally read.</p>
<p>Then there&#39;s pluralization, which English makes look trivial and other languages do not. &quot;1 item&quot; / &quot;2 items&quot; is a two-case rule; Polish and Arabic have far more, and a hardcoded <code>count === 1 ? &#39;item&#39; : &#39;items&#39;</code> is simply broken there. This is why the string libraries use the ICU message format, which encodes plural rules per locale:</p>
<pre><code>{count, plural, one {# item} other {# items}}
</code></pre>
<p>You write the rule once; the library picks the right form for whatever locale is active, using the same <code>Intl</code> plural data. You are never in the business of knowing Polish grammar — you&#39;re in the business of not hardcoding English grammar.</p>
<h2>The React Side: Loading, Not Bundling</h2>
<p>Now the part that&#39;s specifically a React and architecture problem, and the one that separates a demo from a real app: <strong>you cannot ship every language in your bundle.</strong></p>
<p>Here&#39;s the trap. You wire up <a href="https://react.i18next.com">react-i18next</a> or <a href="https://formatjs.io">FormatJS</a>, 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.</p>
<p>The right shape is to treat translation data as something you <em>load on demand</em>, the same way you&#39;d <a href="https://ma-x.im/blog/react-playbook-code-structure">code-split</a> a route. The active locale&#39;s messages load when needed; the rest stay on the server until someone actually switches:</p>
<pre><code class="language-ts">// Load only the active locale&#39;s messages, not all of them.
async function loadMessages(locale: string) {
  const messages = await import(`../locales/${locale}.json`);
  return messages.default;
}
</code></pre>
<p>That dynamic <code>import</code> 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&#39;s statically importing everything because the demo did.</p>
<p>This is the same instinct the whole series keeps returning to: the naive version loads everything eagerly, and the production version loads what&#39;s needed when it&#39;s needed. Translations are just data, and data that most users won&#39;t touch belongs behind a lazy load.</p>
<h2>One More Thing: Direction</h2>
<p>A short but real one, because it&#39;s the part that turns a &quot;translated&quot; app back into an untranslated-looking one. Some languages — Arabic, Hebrew, Persian — read right-to-left, and that&#39;s not a font setting, it&#39;s a layout inversion. Your sidebar moves to the other side, your icons mirror, your text aligns the other way.</p>
<p>The workable answer is to stop thinking in &quot;left&quot; and &quot;right&quot; and start thinking in &quot;start&quot; and &quot;end.&quot; Modern CSS logical properties — <code>margin-inline-start</code> instead of <code>margin-left</code>, <code>padding-inline-end</code> instead of <code>padding-right</code> — flip automatically with the document direction. Set <code>dir=&quot;rtl&quot;</code> on the root for RTL locales and a layout built on logical properties reorients itself. Build it on hardcoded <code>left</code>/<code>right</code> and you get to audit every component by hand. It&#39;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.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>It&#39;s formatting, not translation.</strong> The strings are easy; the values — dates, numbers, currency, plurals — are where locales actually disagree.</li>
<li><strong>Never format values by hand.</strong> Use <code>Intl</code> (via your i18n library). It already knows every locale&#39;s rules; you never do.</li>
<li><strong>Use ICU message format for plurals.</strong> Don&#39;t hardcode English&#39;s two-case grammar into a multi-locale app.</li>
<li><strong>Load locales on demand, don&#39;t bundle them all.</strong> Dynamic-import the active language; leave the rest on the server.</li>
<li><strong>Build layout on logical properties.</strong> <code>start</code>/<code>end</code>, not <code>left</code>/<code>right</code>, so RTL is a setting and not a rewrite.</li>
</ul>
<p>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 &quot;internationalized&quot; 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.</p>
<p>Speaking of values that quietly differ everywhere and ruin your day when you get them wrong — the next article is about <a href="https://ma-x.im/blog/react-playbook-payments">handling money in React</a>: what taking a payment actually requires, and why the client should never be the thing that decides you got paid.</p>
<p>If you&#39;ve got an app that &quot;supports&quot; multiple languages but still leaks the developer&#39;s locale — an English date here, a broken plural there — that&#39;s the formatting-not-translation gap. Tell me what&#39;s showing wrong and I&#39;ll point you at which piece is missing.</p>
]]></content:encoded>
      <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>i18n</category>
      <category>Internationalization</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>The Smeared Component: When Architecture Scatters What Should Stay Together</title>
      <link>https://ma-x.im/blog/smeared-component</link>
      <guid isPermaLink="true">https://ma-x.im/blog/smeared-component</guid>
      <description>FSD is one of the best frontend methodologies out there. Used without thinking, it turns a complex component into a scavenger hunt across eight folders. Here&apos;s what I learned.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/smeared-component/cover.png" alt="The Smeared Component: When Architecture Scatters What Should Stay Together" /></p>
<p>I&#39;ve been writing code since 2003. That&#39;s over twenty years of watching patterns come and go — and occasionally making the same mistakes the patterns were supposed to prevent.</p>
<p>My first serious FSD project was one of those mistakes.</p>
<p>Feature-Sliced Design is one of the most popular frontend architectures today. The docs are clear, the community is active, the rules make sense on paper. You read through it and something clicks: pages here, features there, entities in their own layer, shared utilities at the bottom. Clean. Logical. Structured.</p>
<p>So I followed the rules. Meticulously.</p>
<p>And I ended up with a codebase where a single button — one button that validated a form field — lived across ten folders. Its logic was in <code>features/</code>. Its type was in <code>entities/</code>. Its hook was in <code>shared/hooks/</code>. Its analytics call was somewhere in <code>features/analytics/</code>. Its test was buried in <code>__tests__/features/</code>. To understand what that button did, you had to open half the project.</p>
<p>I was frustrated. For a while, I blamed myself — maybe I didn&#39;t understand FSD well enough. Maybe I was doing it wrong. It took me some time to realize: the problem wasn&#39;t the architecture. The problem was that I had stopped thinking.</p>
<h2>The Smeared Component</h2>
<p>There&#39;s a pattern I&#39;ve started calling the <em>smeared component</em>. You&#39;ve probably seen it. You might have built it.</p>
<p>It happens when a single feature — a payment form, an order widget, a settings panel — gets distributed across the project&#39;s folder structure &quot;correctly.&quot; Each piece goes to its designated layer. Hooks go to <code>hooks/</code>. Types go to <code>types/</code>. API calls go to <code>api/</code>. Validators to <code>validators/</code>. Stories to <code>stories/</code>. Tests to <code>__tests__/</code>.</p>
<p>The result looks organized from the outside. Every folder has the right things in it.</p>
<p>But try to understand the component. Try to fix a bug in it. Try to onboard a new developer onto it. You&#39;ll open one file, realize you need another, follow an import, land in a third folder, lose track of the original file, open a fourth — and fifteen minutes later you&#39;re not sure you&#39;ve seen all the pieces yet.</p>
<blockquote>
<p>Code is read far more often than it is written. Architecture should optimize for understanding — not just separation.</p>
</blockquote>
<p>The smeared component optimizes for separation. It&#39;s perfectly layered. And it&#39;s nearly impossible to hold in your head.</p>
<h2>A Real Example: PaymentMethodSelector</h2>
<p>Let me show you what this looks like in practice.</p>
<p>Imagine a <code>PaymentMethodSelector</code> component — the kind you see on checkout pages. It fetches available payment methods from an API, displays them with icons and descriptions, handles selection state, validates the selected method against order constraints, shows a modal for adding a new card, fires analytics events on selection change, and stores the result in global state.</p>
<p>A well-intentioned FSD implementation might distribute it like this:</p>
<pre><code>src/
  features/
    payment-method/
      ui/
        PaymentMethodSelector.tsx     ← main component
        PaymentMethodOption.tsx
        AddPaymentModal.tsx
  entities/
    payment/
      model/
        payment.store.ts              ← Zustand store
        payment.selectors.ts
      api/
        payment.api.ts                ← API calls
      types/
        payment.types.ts              ← types and interfaces
  shared/
    hooks/
      usePaymentValidation.ts         ← validation logic
    analytics/
      payment.analytics.ts            ← analytics events
    lib/
      formatters/
        payment.formatters.ts         ← formatting helpers
  __tests__/
    features/
      PaymentMethodSelector.test.tsx
  stories/
    PaymentMethodSelector.stories.tsx
</code></pre>
<p>Eight locations. For one component.</p>
<p>Now imagine the validation logic needs to change — a new payment method has different constraints. You update <code>usePaymentValidation.ts</code>. The type needs updating — <code>payment.types.ts</code>. The API response shape changed — <code>payment.api.ts</code>. The analytics event needs a new field — <code>payment.analytics.ts</code>. Update the test — somewhere in <code>__tests__/features/</code>. Update the story. Six folders. One change.</p>
<p>Every bug fix is an archaeological dig. Every refactor is a leap of faith.</p>
<p>Here&#39;s what the same component looks like with colocation:</p>
<pre><code>src/
  features/
    payment-method/
      PaymentMethodSelector.tsx
      PaymentMethodSelector.types.ts
      PaymentMethodSelector.hooks.ts
      PaymentMethodSelector.api.ts
      PaymentMethodSelector.store.ts
      PaymentMethodSelector.analytics.ts
      PaymentMethodSelector.utils.ts
      PaymentMethodSelector.test.tsx
      PaymentMethodSelector.stories.tsx
      ui/
        PaymentMethodOption.tsx
        AddPaymentModal.tsx
</code></pre>
<p>Everything that changes together lives together. One folder. One mental unit. One place to look.</p>
<h2>&quot;But FSD Doesn&#39;t Allow That&quot;</h2>
<p>I can already hear the objection: &quot;FSD has rules. Types belong in entities. Hooks belong in shared. You can&#39;t just put everything in features.&quot;</p>
<p>Here&#39;s the thing: FSD is a tool. Not a commandment.</p>
<p>The methodology was designed for the common case — and the common case is a typical CRUD feature with thin logic and shared primitives. For a simple <code>UserAvatar</code> component, fine — put the type in entities, the API call in shared. The overhead is minimal because the component is minimal.</p>
<p>But for a complex, domain-rich feature like <code>PaymentMethodSelector</code>? The cost changes. Every &quot;correct&quot; placement is a navigation tax you pay every time you touch the component.</p>
<p>FSD actually allows adaptation. You can add a <code>core/</code> layer for truly foundational cross-cutting concerns. You can extend <code>shared/</code> with domain-specific subfolders. You can colocate everything within a feature when that feature is complex enough to justify it. The methodology explicitly says the structure should serve the project — not the other way around.</p>
<p>I&#39;ve worked on a project where the team extended FSD&#39;s layer model to add a <code>core/</code> layer for infrastructure-level services: logging, auth tokens, global config. Things that don&#39;t belong to any feature but are too specific for <code>shared/</code>. It worked cleanly. No FSD rule was violated — because the rule was never &quot;these are the only layers you can have.&quot; The rule was &quot;maintain the import direction and keep dependencies explicit.&quot;</p>
<blockquote>
<p>Architecture is not what you do by the rules. It&#39;s how you solve the problem in front of you — in a way that stays solvable tomorrow.</p>
</blockquote>
<h2>Why We Keep Doing This</h2>
<p>The reason we smear components is usually good intentions. We read the docs. We follow the patterns. We want the project to be &quot;clean.&quot; We&#39;ve been burned by messy codebases before and we never want to go back.</p>
<p>So we apply the architecture top to bottom, folder by folder, layer by layer. And it works. Until the project grows. Until the component gets complex. Until someone new joins and spends three days just finding where things are.</p>
<p>I&#39;ve worked in legacy codebases where the original authors followed every rule. The folder structure is immaculate. And the cognitive load of working in it is brutal — because everything <em>looks</em> right but nothing <em>reads</em> right. Opening a component means opening a new tab for the hook, a new tab for the type, a new tab for the API call. Your brain is juggling six contexts at once.</p>
<p>I&#39;ve been that original author too, on my first FSD project. I was so focused on placing files correctly that I stopped asking whether the placement made the code better.</p>
<h2>What Architecture Actually Optimizes For</h2>
<p>Good architecture optimizes for:</p>
<ul>
<li><strong>Locality</strong> — related things are close to each other</li>
<li><strong>Discoverability</strong> — you can find what you&#39;re looking for without a map</li>
<li><strong>Changeability</strong> — when requirements change, the blast radius is predictable</li>
<li><strong>Cognitive simplicity</strong> — a developer can hold the relevant context in their head</li>
</ul>
<p>Less useful to optimize for:</p>
<ul>
<li>Folder aesthetics</li>
<li>Layer purity</li>
<li>DRY at the cost of everything else</li>
</ul>
<p>These aren&#39;t opposites. A well-applied FSD project can nail all the good ones. But &quot;well-applied&quot; requires judgment, not just rule-following.</p>
<p>The principle I keep coming back to: <strong>things that change together should live together.</strong> It sounds simple. It&#39;s not always easy to apply. But it&#39;s the right question to ask before you decide where a file goes.</p>
<h2>A Practical Test</h2>
<p>Before placing a file in its &quot;architecturally correct&quot; location, ask yourself:</p>
<ol>
<li>If I need to change this file, what other files will I need to open at the same time?</li>
<li>Are those files nearby — or spread across the project?</li>
<li>If a new developer needed to understand this feature, how many folders would they need to open?</li>
</ol>
<p>If the answers make you uncomfortable, you&#39;re probably optimizing for layer purity instead of developer experience. Reconsider the placement.</p>
<p>This isn&#39;t &quot;ignore architecture.&quot; It&#39;s the opposite — it&#39;s using the architecture deliberately. FSD, done thoughtfully, is excellent. Applied mechanically — rule by rule, folder by folder, without judgment — it produces smeared components and frustrated developers.</p>
<p>I&#39;ve worked on both kinds of projects. The difference wasn&#39;t the architecture they used. It was the thinking behind it.</p>
]]></content:encoded>
      <pubDate>Sat, 09 May 2026 00:00:00 GMT</pubDate>
      <category>Architecture</category>
      <category>React</category>
      <category>FSD</category>
      <category>DX</category>
      <category>Frontend</category>
    </item>
    <item>
      <title>Real-Time React: When the Server Has Something to Say</title>
      <link>https://ma-x.im/blog/react-playbook-websockets</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-websockets</guid>
      <description>Every data pattern so far has the client asking and the server answering. Real-time inverts that — the server speaks first, and your UI has to be listening. The mistake is treating that live connection as a second, parallel source of truth. Here&apos;s how I keep a React app live without ending up with two states that disagree.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-websockets/cover.png" alt="Real-Time React: When the Server Has Something to Say" /></p>
<p>Everything in this series so far has had the same conversation shape: the client asks a question, the server answers. You call <code>useQuery</code>, the request goes out, the response comes back. The client always speaks first. It&#39;s a comfortable model, and it&#39;s most of what a web app does.</p>
<p>Real-time flips the direction. Now the server speaks first — a new message lands, a price ticks, another user drags a card across the board — and your UI has to already be listening, with no request that triggered it. That inversion is the whole thing that makes real-time feel different to build, and it&#39;s where people reach for a WebSocket and immediately make the mistake that poisons the rest of the feature.</p>
<p>The mistake is treating the live connection as a <em>second source of truth</em>. You already have server state, cached and managed. Then you open a socket, start catching messages, and stuff them into a separate piece of React state. Now you have two stores that are supposed to represent the same reality, updated through two different channels, and they drift. This article is about not doing that — how I wire a live connection into the state I already have, so the app stays live <em>and</em> consistent.</p>
<h2>First: Do You Even Need a WebSocket?</h2>
<p>Before the socket, a fork in the road, because &quot;real-time&quot; gets reached for reflexively and WebSockets are the heaviest option.</p>
<p>There are really three tools, and they&#39;re not interchangeable. <strong>Polling</strong> — just refetch on an interval — is the humble one everyone forgets. If &quot;real-time&quot; means &quot;within thirty seconds,&quot; a <code>refetchInterval</code> on an existing query is the entire feature, no new infrastructure, and you should feel no shame about it. <strong>Server-Sent Events (SSE)</strong> are a one-way stream from server to client over plain HTTP: perfect when the server needs to push but the client never pushes back — notifications, a live feed, status updates. <strong>WebSockets</strong> are the full bidirectional pipe, and you want them when <em>both</em> sides talk continuously: chat, collaborative editing, multiplayer anything.</p>
<p>The honest default is to start at the top of that list and only move down when the requirement forces it. A surprising number of &quot;we need WebSockets&quot; features are actually a polling interval or an SSE stream wearing a costume. Reach for the bidirectional socket when you genuinely have bidirectional, continuous communication — not before.</p>
<h2>The Core Idea: One Source of Truth</h2>
<p>Here&#39;s the principle that makes the rest of this easy, and it&#39;s worth stating before any code:</p>
<blockquote>
<p>The live connection is a delivery mechanism, not a place to store state. Messages arrive on the socket and are handed to your existing cache. They don&#39;t get their own store.</p>
</blockquote>
<p>You spent the <a href="https://ma-x.im/blog/react-playbook-data-fetching">data fetching article</a> setting up TanStack Query as the manager of server state. Real-time doesn&#39;t replace that — it feeds it. When a WebSocket message arrives, its job isn&#39;t to become a new piece of state you render directly. Its job is to <em>update the cache that&#39;s already the source of truth</em>. The socket is a courier. It delivers news to the store; it isn&#39;t the store.</p>
<p>Get this one idea right and the two-sources-of-truth bug simply can&#39;t happen, because there&#39;s still only one source of truth. The socket just has a new way of writing to it.</p>
<h2>Wiring a Socket Into the Cache</h2>
<p>Concretely, there are two moves when a message arrives, and choosing between them is most of the skill.</p>
<p>The blunt move is <strong>invalidate</strong>: a message says &quot;something changed over here,&quot; and you tell TanStack Query to refetch that query. You don&#39;t trust the message to carry the new data — you use it purely as a signal, and the refetch pulls fresh truth from the server.</p>
<pre><code class="language-tsx">import { useQueryClient } from &#39;@tanstack/react-query&#39;;
import { useEffect } from &#39;react&#39;;

function useTicketUpdates(socket: WebSocket) {
  const queryClient = useQueryClient();

  useEffect(() =&gt; {
    function handleMessage(event: MessageEvent) {
      const msg = JSON.parse(event.data);
      if (msg.type === &#39;ticket.updated&#39;) {
        // The socket is just a signal. Refetch the real data.
        queryClient.invalidateQueries({ queryKey: [&#39;tickets&#39;, msg.ticketId] });
      }
    }

    socket.addEventListener(&#39;message&#39;, handleMessage);
    return () =&gt; socket.removeEventListener(&#39;message&#39;, handleMessage);
  }, [socket, queryClient]);
}
</code></pre>
<p>Invalidation is safe and simple: the server stays authoritative, and you never render data the socket made up. The cost is an extra round trip per message, which is fine for low-frequency events and wrong for a firehose.</p>
<p>The sharp move is <strong>write the message straight into the cache</strong> with <code>setQueryData</code>, when the message already carries the full new value and refetching would be wasteful — a chat message, a price tick:</p>
<pre><code class="language-tsx">function handleMessage(event: MessageEvent) {
  const msg = JSON.parse(event.data);
  if (msg.type === &#39;chat.message&#39;) {
    queryClient.setQueryData([&#39;chat&#39;, msg.roomId], (old: Message[] = []) =&gt; [
      ...old,
      msg.message,
    ]);
  }
}
</code></pre>
<p>Same principle either way — the socket updates the one cache — but <code>setQueryData</code> skips the round trip by trusting the payload. My rule of thumb: <strong>invalidate for &quot;something changed,&quot; write directly for &quot;here is exactly what changed.&quot;</strong> High-frequency streams where a refetch-per-message would melt your server are the clear case for writing directly; everything else, start with invalidate because it&#39;s harder to get wrong.</p>
<h2>The Part Everyone Underestimates: The Connection Lifecycle</h2>
<p>Opening a socket is the easy 20% again. The other 80% is that connections are <em>not reliable</em>, and a real-time feature is only as good as how it handles a connection that isn&#39;t there.</p>
<p>The network drops. The laptop sleeps and wakes. A deploy restarts the server and every socket dies at once. If your feature assumes a persistent, healthy connection, it silently stops updating and the user has no idea — which is worse than a visible error, because they&#39;re now looking at stale data they believe is live. So the lifecycle work is not optional polish; it&#39;s the feature. You need <strong>reconnection</strong> with backoff so a blip heals itself instead of ending the session. You need to <strong>resync on reconnect</strong> — when the socket comes back, invalidate the relevant queries and refetch, because you almost certainly missed messages while it was down, and the socket can&#39;t replay history it never delivered. And you owe the user <strong>connection status</strong> in the UI, even something quiet, so &quot;live&quot; and &quot;trying to reconnect&quot; don&#39;t look identical.</p>
<p>This is exactly why not hand-rolling the raw <code>WebSocket</code> for anything serious is the right call. A library like <a href="https://socket.io">Socket.IO</a> exists to handle reconnection, fallbacks, and connection state for you — the same trade as every other article in this series, where the value isn&#39;t the raw capability but the library that manages its messy edges. Raw <code>WebSocket</code> is fine to learn on and fine for a toy. For a feature people depend on, let something else own the reconnection logic, and spend your attention on what the messages <em>mean</em>.</p>
<h2>How I&#39;d Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>Don&#39;t reach for WebSockets reflexively.</strong> Polling and SSE cover more cases than people admit. Use the bidirectional socket only for genuinely bidirectional, continuous features.</li>
<li><strong>The connection is a courier, not a store.</strong> One source of truth stays your TanStack Query cache; the socket writes to it.</li>
<li><strong>Invalidate for &quot;something changed,&quot; <code>setQueryData</code> for &quot;here&#39;s exactly what changed.&quot;</strong> Start with invalidate; reach for direct writes when the frequency demands it.</li>
<li><strong>The lifecycle is the feature.</strong> Reconnect with backoff, resync on reconnect, show connection status. A silently dead socket is worse than an error.</li>
<li><strong>Let a library own the connection.</strong> Hand-rolled raw sockets are for learning, not for things people rely on.</li>
</ul>
<p>The thing that used to make real-time feel hard, for me, was imagining it as a whole separate system bolted onto the app — its own state, its own rules, its own bugs. It isn&#39;t. It&#39;s one new way for data to arrive into the store you already have. Once the socket stops being a source of truth and becomes a courier delivering to your cache, the &quot;two states that disagree&quot; class of bug disappears, and what&#39;s left is just careful handling of a connection that can drop. That part takes discipline, but it&#39;s a known problem with known answers.</p>
<p>Next up, a different kind of &quot;the same app, but harder&quot;: <a href="https://ma-x.im/blog/react-playbook-i18n">internationalization in React</a> — which turns out to be far less about translating strings and far more about formatting, loading, and everything that quietly breaks when your app leaves one language.</p>
<p>If you&#39;ve got a real-time feature where the UI occasionally shows stale data or two views disagree, that&#39;s almost always a two-sources-of-truth problem or a missed-resync-on-reconnect problem. Tell me the symptom and I&#39;ll tell you which.</p>
]]></content:encoded>
      <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>WebSockets</category>
      <category>Real-time</category>
      <category>TanStack Query</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>AI in a React App: Past the Demo, Into the Product</title>
      <link>https://ma-x.im/blog/react-playbook-ai</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-ai</guid>
      <description>The demos are intoxicating — a chat box, a streaming response, thirty lines of code and it feels like magic. Then you try to ship it and the hard parts show up all at once: streaming UI, structured output you can trust, state that doesn&apos;t fight you. Here&apos;s what building AI into a real React app actually takes.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-ai/cover.png" alt="AI in a React App: Past the Demo, Into the Product" /></p>
<p>The first AI demo you build feels like cheating. A text input, a fetch to a model, a response on screen — thirty lines, maybe less, and suddenly your app <em>talks</em>. It&#39;s genuinely exciting, and that excitement is exactly the trap. Because the demo is the easy 20%. The moment you try to put that chat box in front of real users, the other 80% arrives all at once, and none of it is about the model.</p>
<p>The response doesn&#39;t appear all at once — it streams, token by token, and your UI has to handle that gracefully. You don&#39;t just want text back, you want <em>structured</em> data you can render into a component, and the model doesn&#39;t always cooperate. The request takes seconds, sometimes fails halfway, and your app has to stay honest about loading and error states the whole time. This is the part the demos skip, and it&#39;s the entire job.</p>
<p>So this article isn&#39;t &quot;how to call an LLM.&quot; Calling the model is trivial. This is about the React-side reality of turning that call into something you&#39;d actually ship — the streaming, the structure, the state — and where the current tools genuinely help versus where you&#39;re still on your own.</p>
<h2>Why AI Broke My Normal Data Layer</h2>
<p>Up to this point in the series, every data problem had the same shape. You fire a request, you wait, you get a response, <a href="https://ma-x.im/blog/react-playbook-data-fetching">TanStack Query</a> caches it and hands it to your component. Request, response, done. It&#39;s a clean model and it covers almost everything.</p>
<p>AI breaks it in one specific way: the response isn&#39;t a single event, it&#39;s a <em>stream</em>. The model produces its answer incrementally, and the whole point — the thing that makes it feel alive — is showing those tokens as they arrive. A spinner that sits for eight seconds and then dumps a wall of text feels broken, even when it&#39;s technically correct. The perceived quality of an AI feature is almost entirely about the streaming.</p>
<p>That&#39;s a different primitive than request/response. You&#39;re not awaiting a value; you&#39;re consuming a sequence over time and rendering each chunk. Trying to force that into a normal <code>useQuery</code> fights the grain of the tool. This is why AI-specific React libraries exist — not because calling a model is hard, but because <em>streaming a model&#39;s output into a UI</em> is a genuinely different problem, and doing it by hand means manually managing readers, buffers, partial state, and cancellation.</p>
<h2>The Streaming Chat, Handled For You</h2>
<p>The good news is that this exact problem is now well-solved. The <a href="https://ai-sdk.dev">Vercel AI SDK</a> (and the TanStack-flavored patterns around it) exists precisely to make streaming chat a solved problem on the React side. Its <code>useChat</code> hook is the <code>useQuery</code> of this world — it owns the messy part so you don&#39;t.</p>
<pre><code class="language-tsx">import { useChat } from &#39;ai/react&#39;;

export function SupportChat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } =
    useChat({ api: &#39;/api/chat&#39; });

  return (
    &lt;div className=&quot;support-chat&quot;&gt;
      {messages.map((m) =&gt; (
        &lt;div key={m.id} data-role={m.role}&gt;
          {m.content}
        &lt;/div&gt;
      ))}

      &lt;form onSubmit={handleSubmit}&gt;
        &lt;input value={input} onChange={handleInputChange} disabled={isLoading} /&gt;
        &lt;button type=&quot;submit&quot; disabled={isLoading}&gt;Send&lt;/button&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Look at what you <em>didn&#39;t</em> write. No stream reader. No manual buffering of partial tokens. No hand-rolled &quot;append this chunk to the last message&quot; logic. <code>messages</code> updates as tokens arrive, so the response types itself onto the screen. <code>isLoading</code> gives you the state to disable the input and show activity. The entire streaming problem — the thing that would take a day to get right by hand and stay subtly buggy — is inside the hook.</p>
<p>This is the same lesson as every other article in this series: the value isn&#39;t the raw capability, it&#39;s a library that takes the awkward part and gives you a clean React interface for it. <code>useChat</code> is to streaming what TanStack Query is to caching.</p>
<h2>The Part That Actually Trips People Up: Structured Output</h2>
<p>Streaming text is solved. Here&#39;s where it gets real: most of the time you don&#39;t want a paragraph of prose, you want <em>data</em>. You want the model to extract three fields from an invoice, or classify a support ticket, or return a list of suggested tags — and then you want to render that into an actual component, not a string.</p>
<p>The naive approach is to ask the model for JSON in the prompt and <code>JSON.parse</code> the result. Do this in a demo and it works. Do it in production and it breaks, because the model is a text generator, not an API. Sometimes it wraps the JSON in ````json fences. Sometimes it adds a friendly &quot;Sure, here&#39;s the data:&quot; preamble. Sometimes it hallucinates a field. <code>JSON.parse</code> throws, and now your feature is down because the model was chatty.</p>
<p>The current answer is to stop hoping and start enforcing. You define a <a href="https://ma-x.im/blog/react-playbook-form-libraries">Zod</a> schema for the shape you need and let the SDK constrain the model to it:</p>
<pre><code class="language-ts">import { generateObject } from &#39;ai&#39;;
import { z } from &#39;zod&#39;;

const ticketSchema = z.object({
  category: z.enum([&#39;billing&#39;, &#39;technical&#39;, &#39;account&#39;, &#39;other&#39;]),
  priority: z.enum([&#39;low&#39;, &#39;medium&#39;, &#39;high&#39;]),
  summary: z.string(),
});

const { object } = await generateObject({
  model,
  schema: ticketSchema,
  prompt: `Classify this support ticket: ${ticketText}`,
});

// object is typed as { category: ..., priority: ..., summary: string }
// and validated — not a hopeful JSON.parse.
</code></pre>
<p>The difference is the difference between a demo and a product. <code>object</code> isn&#39;t a string you&#39;re praying parses — it&#39;s a validated, typed value the SDK guarantees matches your schema, or the call fails loudly instead of feeding garbage into your UI. Notice this is the <em>exact same Zod schema pattern</em> from the forms article, pointed at a new problem. You already know this tool. The model becomes just another untrusted input source you validate at the boundary — which, if you&#39;ve read the <a href="https://ma-x.im/blog/react-playbook-authentication">authentication</a> piece, is exactly how you should treat everything crossing into your app.</p>
<blockquote>
<p>Treat model output like user input, not like an API response. It is text that happens to look structured. Validate it, or it will embarrass you in production.</p>
</blockquote>
<h2>Where the Tools Stop and You Start</h2>
<p>I want to be honest about the edges, because the ecosystem is young and the demos oversell.</p>
<p>The streaming and structured-output tools are solid. What they don&#39;t solve is everything <em>around</em> the call. Cost — every token is money, and a chat feature with no limits is a bill waiting to happen; you need throttling and usage caps, and those are your problem, not the SDK&#39;s. Latency — responses take seconds, and no amount of UI polish makes a slow model fast; you design around the wait, you don&#39;t remove it. Failure — models time out, hit rate limits, and return nonsense, so every AI feature needs the same <a href="https://ma-x.im/blog/react-playbook-error-handling">error handling</a> discipline as any other flaky network dependency, plus a graceful answer for &quot;the model gave me something useless.&quot;</p>
<p>And the honest constraint underneath all of it: the model is non-deterministic. The same input can produce different output. That breaks a core assumption every other part of your app relies on. You can&#39;t snapshot-test it the normal way, you can&#39;t guarantee a given response, and you have to design UI that stays usable when the answer is wrong. That&#39;s not a library gap that&#39;ll be patched next quarter. That&#39;s the nature of the thing, and building AI features well means designing for it rather than pretending it away.</p>
<h2>How I&#39;d Actually Approach It</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>Streaming is the product, not a detail.</strong> The feel of an AI feature is the streaming. Don&#39;t fake it with a spinner.</li>
<li><strong>Don&#39;t hand-roll the stream.</strong> Use <code>useChat</code> / the AI SDK. It&#39;s the <code>useQuery</code> of streaming — it owns the messy part.</li>
<li><strong>Never trust free-form JSON.</strong> Use <code>generateObject</code> with a Zod schema. Treat model output as untrusted input, validated at the boundary.</li>
<li><strong>Budget for cost, latency, and failure from day one.</strong> They&#39;re your responsibility, not the SDK&#39;s, and they don&#39;t go away.</li>
<li><strong>Design for non-determinism.</strong> The answer can be wrong. The UI has to survive that.</li>
</ul>
<p>The demos make AI look like a feature you drop in. It isn&#39;t. It&#39;s a new kind of data source — streaming, untrusted, non-deterministic, and metered — and the work is treating it with the same engineering seriousness you&#39;d give any other unreliable dependency. The tools have gotten genuinely good at the streaming and the structure. The judgment — where to use it, how to fail, what to guarantee — is still entirely yours, and that&#39;s the part worth getting right.</p>
<p>The next article stays in real-time territory but drops the AI: <a href="https://ma-x.im/blog/react-playbook-websockets">WebSockets and real-time in React</a> — how to keep a UI live when the server has something to say, and how it fits with the TanStack Query cache you already have.</p>
<p>If you&#39;re building an AI feature and it works beautifully in the demo but falls apart with real traffic, that gap is almost always one of these — streaming, structure, or the metered/non-deterministic reality underneath. Tell me where it breaks and I&#39;ll tell you which one it is.</p>
]]></content:encoded>
      <pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>AI</category>
      <category>TanStack</category>
      <category>Streaming</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Testing React Without Drowning: What Each Test Actually Buys You</title>
      <link>https://ma-x.im/blog/react-playbook-testing</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-testing</guid>
      <description>Most testing advice is either &quot;100% coverage&quot; zealotry or &quot;tests slow you down&quot; cynicism. Both miss the point. A test is a trade — effort now for confidence later — and the skill is knowing which trades pay off. Here&apos;s how I test React without a brittle suite that everyone hates.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-testing/cover.png" alt="Testing React Without Drowning: What Each Test Actually Buys You" /></p>
<p>Two developers look at the same untested codebase and reach opposite conclusions. One says &quot;this is reckless, we need 100% coverage before we ship anything else.&quot; The other says &quot;tests are why we&#39;re slow, they break every time we change a line, let&#39;s just be careful instead.&quot; I&#39;ve been in rooms with both, and here&#39;s the uncomfortable thing: they&#39;re both partly right, and both are optimizing the wrong variable.</p>
<p>Because a test isn&#39;t a virtue and it isn&#39;t a tax. It&#39;s a trade — effort and speed now, in exchange for confidence and safety later. Some of those trades are wildly worth it. Some cost more than they&#39;ll ever return. The whole skill of testing isn&#39;t writing tests; it&#39;s knowing which trade you&#39;re making and whether it pays off.</p>
<p>So this article isn&#39;t a testing tutorial and it definitely isn&#39;t a coverage sermon. It&#39;s how I actually decide what to test in a React app, so I end up with a suite that catches real bugs without becoming the brittle, resented thing that everyone works around.</p>
<h2>The Pain Each Kind of Test Removes</h2>
<p>Forget the pyramid diagram for a second. The useful question isn&#39;t &quot;what are the types of tests,&quot; it&#39;s &quot;what specific pain does each one remove.&quot; Because if a test isn&#39;t removing pain, you&#39;re paying for nothing.</p>
<p><strong>Unit tests</strong> remove the pain of a single piece of logic being subtly wrong. A pricing calculation, a date formatter, a reducer with tricky edge cases. Fast, focused, and they pin down the exact spot a bug lives.</p>
<p><strong>Component tests</strong> remove the pain of a UI piece behaving wrong when a user touches it. Does the form show the error, does the button disable while submitting, does the list render what it&#39;s handed. This is where most React testing value actually lives, and we&#39;ll spend the most time here.</p>
<p><strong>End-to-end (E2E) tests</strong> remove the pain of the whole thing being broken even though every piece works alone. The classic disaster: every unit passes, and login is still broken because two correct parts don&#39;t fit together. E2E drives a real browser through a real flow and catches exactly that.</p>
<p>Each targets a different fear. Unit: &quot;is this logic right?&quot; Component: &quot;does this behave when used?&quot; E2E: &quot;does the actual journey work?&quot; You don&#39;t need all three everywhere. You need each one where its specific fear is real.</p>
<h2>The One Rule That Fixed My Component Tests</h2>
<p>For a long time my React tests were brittle. I&#39;d change an implementation detail — rename a state variable, restructure a handler, swap a class name — and a dozen tests would go red without a single actual bug. That&#39;s the experience that makes people hate testing, and it turns out it was my fault, not testing&#39;s.</p>
<p>The rule that fixed it: <strong>test behavior, not implementation. Test what the user experiences, not how the component achieves it.</strong></p>
<p><a href="https://testing-library.com">React Testing Library</a> is built entirely around this idea, and once it clicked, my tests stopped breaking for stupid reasons. You don&#39;t query for internal state or component instances. You query the DOM the way a user perceives it — by text, by role, by label — and you interact the way a user does.</p>
<pre><code class="language-tsx">import { render, screen } from &#39;@testing-library/react&#39;;
import userEvent from &#39;@testing-library/user-event&#39;;

test(&#39;shows an error when submitting an empty policy holder name&#39;, async () =&gt; {
  render(&lt;PolicyForm /&gt;);

  // Interact like a user: find the button by its accessible role.
  await userEvent.click(screen.getByRole(&#39;button&#39;, { name: /save/i }));

  // Assert on what the user actually sees.
  expect(screen.getByText(/holder name is required/i)).toBeInTheDocument();
});
</code></pre>
<p>Look at what that test doesn&#39;t know. It doesn&#39;t know if <code>PolicyForm</code> uses <code>useState</code> or a form library, whether the error lives in local state or context, what the handler is called. I can rewrite the entire internals of that component and, as long as an empty submit still shows that error, the test stays green. That&#39;s the point: the test is coupled to the <em>behavior the user relies on</em>, not the code I happened to write. Implementation-coupled tests break when you refactor; behavior-coupled tests break when you regress. Only one of those is useful.</p>
<p>This connects straight back to the <a href="https://ma-x.im/blog/react-playbook-form-libraries">forms article</a>: the reason I could confidently recommend a specific form library is that behavior tests let me swap implementations without rewriting the test suite. Good tests make good architecture decisions cheaper to reverse.</p>
<h2>Vitest, Quickly</h2>
<p>A note on tooling, because it changed and the old advice lingers. For years Jest was the default. Today, for a Vite-based app — which is most new React apps — <strong>Vitest</strong> is the natural pick. It reuses your existing Vite config and transforms, so there&#39;s no separate parallel build setup to maintain, and it&#39;s fast. The API is close enough to Jest that everything you know transfers. Pair it with React Testing Library and you have the whole component-testing stack. I won&#39;t belabor it: if you&#39;re on Vite, use Vitest, and move on to the part that matters, which is deciding what to test.</p>
<h2>What I Actually Test, and What I Don&#39;t</h2>
<p>Here&#39;s where the &quot;it&#39;s a trade&quot; framing turns into decisions. Not everything deserves a test, and pretending otherwise is how you get a slow, resented suite.</p>
<p><strong>I test the things that are painful when wrong.</strong> Business logic with real consequences — pricing, permissions, anything money- or data-integrity-related. Complex conditional UI, the kind with enough branches that I can&#39;t hold them in my head. Bugs I&#39;ve already fixed once, which get a regression test so they can&#39;t come back. Custom hooks with real logic. These earn their keep because the cost of them silently breaking is high.</p>
<p><strong>I don&#39;t test the things that are cheap to verify by looking.</strong> A component that renders a prop into a heading. Trivial passthrough. Third-party libraries — that&#39;s their job, not mine. Static markup with no logic. Writing a test for <code>&lt;h1&gt;{title}&lt;/h1&gt;</code> doesn&#39;t remove any pain; it just adds a file that has to change every time the markup does.</p>
<p>The honest heuristic I use: <strong>before writing a test, I ask what bug it would catch and how bad that bug would be.</strong> If I can&#39;t name a realistic bug, or the bug would be obvious and harmless, I skip it. If the bug would be subtle, expensive, or embarrassing, I write the test first. Coverage percentage never enters the decision — a codebase at 100% coverage full of <code>&lt;h1&gt;</code> tests is worse than one at 60% that covers every pricing path, because the first one is mostly noise you have to maintain.</p>
<blockquote>
<p>Coverage measures how much code ran during tests. It says nothing about whether the tests would catch a bug that matters. Chasing the number optimizes the wrong thing.</p>
</blockquote>
<h2>E2E: A Little Goes a Long Way</h2>
<p>End-to-end tests are the most expensive to write and maintain and the slowest to run, so I&#39;m deliberately stingy with them — but the few I keep are the ones I&#39;d least want to live without.</p>
<p>I don&#39;t E2E every screen. I E2E the handful of flows where &quot;broken and nobody noticed&quot; would be a genuine disaster: the signup and login path, the checkout or core conversion flow, the one or two journeys the business actually runs on. Tools like Playwright drive a real browser through those, catching the integration failures that unit and component tests structurally can&#39;t see.</p>
<pre><code class="language-ts">import { test, expect } from &#39;@playwright/test&#39;;

test(&#39;a user can sign in and reach the dashboard&#39;, async ({ page }) =&gt; {
  await page.goto(&#39;/login&#39;);
  await page.getByLabel(&#39;Email&#39;).fill(&#39;user@example.com&#39;);
  await page.getByLabel(&#39;Password&#39;).fill(&#39;correct-horse&#39;);
  await page.getByRole(&#39;button&#39;, { name: /sign in/i }).click();

  await expect(page.getByRole(&#39;heading&#39;, { name: /dashboard/i })).toBeVisible();
});
</code></pre>
<p>Notice it&#39;s the same philosophy as the component test — interact by role and label, assert on what the user sees — just scaled up to the whole journey through a real browser. A small number of these, guarding the flows that matter most, buys an enormous amount of confidence for the cost. Try to E2E everything and you get a slow, flaky suite people disable. Guard the critical few and you get a safety net people trust.</p>
<h2>How This All Fits Together</h2>
<p>Strip it to decisions:</p>
<ul>
<li><strong>A test is a trade, not a virtue.</strong> Effort now for confidence later — only worth it when the confidence is worth more than the effort.</li>
<li><strong>Match the test to the fear.</strong> Unit for logic, component for behavior, E2E for the critical journey.</li>
<li><strong>Test behavior, not implementation.</strong> Query and interact like a user, so refactors don&#39;t turn your suite red for no reason.</li>
<li><strong>On Vite, use Vitest</strong> with React Testing Library, and stop thinking about tooling.</li>
<li><strong>Ask &quot;what bug would this catch, and how bad is it?&quot;</strong> before writing any test. Skip the ones with no honest answer.</li>
<li><strong>Ignore the coverage number.</strong> Cover what hurts when it breaks.</li>
</ul>
<p>I used to think the goal of testing was a high coverage number, and I wrote a lot of useless tests chasing it. The goal is confidence — the specific, earned feeling that you can change this code and not break something that matters. A suite that gives you that at 60% honest coverage is worth more than one that gives you false comfort at 100%. Tests aren&#39;t there to make a dashboard green. They&#39;re there so you can move fast without being afraid, and every test that doesn&#39;t serve that is just weight you&#39;re carrying.</p>
<p>This is the last piece of the &quot;build a complete app&quot; arc — start a project, structure it, fetch data, route, manage state, style, build forms and charts, authenticate, talk to a backend, render on the server, store data, deploy, and now test it. From here the series turns to the sharper edges: the next article is about <a href="https://ma-x.im/blog/react-playbook-ai">React and AI</a> — what the current tools actually make possible in a real app, past the demos.</p>
<p>If you&#39;ve got a test suite that everyone quietly works around — slow, brittle, red for the wrong reasons — that&#39;s usually a &quot;testing implementation instead of behavior&quot; problem, and it&#39;s fixable. Tell me what breaks when you refactor, and I&#39;ll tell you what I&#39;d change.</p>
]]></content:encoded>
      <pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Testing</category>
      <category>Vitest</category>
      <category>Testing Library</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Hosting a React App in 2026: From DevOps Project to Git Push</title>
      <link>https://ma-x.im/blog/react-playbook-hosting-railway</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-hosting-railway</guid>
      <description>Deployment used to be a separate discipline — servers, pipelines, a person whose whole job was getting code to production. Modern platforms compressed it into a git push. Here&apos;s how to think about hosting a React app, what the tiers actually mean, and where the magic stops.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-hosting-railway/cover.png" alt="Hosting a React App in 2026: From DevOps Project to Git Push" /></p>
<p>The first time I deployed something to production, it took a week and a person whose entire job was making deployments happen. There was a server to configure, a pipeline to wire, environment variables to get wrong in six interesting ways, and a deploy checklist that read like a pre-flight sequence. Shipping was an event.</p>
<p>The last thing I deployed took a git push. No server, no pipeline I wrote, no checklist. I pushed to <code>main</code>, and a minute later it was live with a URL and HTTPS I didn&#39;t configure. Same person, same nervousness the first time — and almost none of the work.</p>
<p>That compression is the story of modern hosting, and it&#39;s easy to take for granted until you realize it quietly changed who gets to ship things. When deployment stops being a discipline, a single developer can put a real, full-stack app in front of real users without a DevOps team standing behind them. So this article is about how to think about hosting a React app now — not a tutorial for one platform, but the mental model, the tiers, and the place where the magic politely stops.</p>
<h2>First, What Are You Actually Deploying?</h2>
<p>The most common hosting confusion isn&#39;t about platforms. It&#39;s that &quot;deploy my React app&quot; means two completely different things depending on what you built, and picking the wrong kind of host is where people get stuck.</p>
<p><strong>A static site.</strong> If your app is client-side rendered or statically generated — this very blog is a static export — then &quot;deploying&quot; is just serving files. HTML, CSS, JS, sitting on a CDN. There&#39;s no server running your code; there&#39;s a very fast file server near your users. This is the cheapest, simplest, most durable thing you can ship, and platforms like GitHub Pages, Netlify, Cloudflare Pages, and Vercel do it beautifully, often free.</p>
<p><strong>An app with a server.</strong> The moment you have <a href="https://ma-x.im/blog/react-playbook-ssr-tanstack-start">SSR</a>, server functions, or a <a href="https://ma-x.im/blog/react-playbook-database-turso">backend and database</a>, there&#39;s actual code that has to run on demand, somewhere, all the time. That&#39;s not a file server. That&#39;s a running process, and it needs a host that keeps it alive, scales it, and gives it access to your database.</p>
<p>Almost every hosting mistake I see is someone trying to fit one of these into the other&#39;s home — pushing a full-stack app onto a static-only host and wondering why the server code never runs, or paying for an always-on server to deliver what is really just a folder of files. Know which one you have first. Everything else follows from that.</p>
<h2>The Static Path: Basically Solved</h2>
<p>If you&#39;re shipping a static site, the good news is you barely have to think. Connect your Git repo to Netlify, Cloudflare Pages, Vercel, or GitHub Pages; every push to your main branch builds and deploys automatically; you get a global CDN, HTTPS, and preview URLs for pull requests without configuring any of it.</p>
<p>This series&#39; own site is the proof — a Next.js static export pushed to a branch, built by CI, served from a CDN, on a custom domain. There is no server. There is nothing to keep running. And that&#39;s exactly why it&#39;s the most robust option: the thing that can&#39;t crash at 3am is the thing that isn&#39;t running. If your app can be static, let it be static, and enjoy how little there is to worry about.</p>
<h2>The Full-Stack Path: Where Railway Comes In</h2>
<p>The interesting decisions start when you have a server to run. Now you need a platform that takes your code, containerizes it, keeps the process alive, connects it to a database, manages environment variables and secrets, and scales it when traffic climbs — all without you learning Kubernetes.</p>
<p>This is the category <strong>Railway</strong> lives in, alongside Render, Fly.io, and the more serverless-shaped Vercel functions model. What they share is the same compression that hit static hosting, now applied to running servers: you connect a repo, and a git push builds and deploys a live process.</p>
<pre><code class="language-bash"># The entire &quot;deploy a full-stack app&quot; story, conceptually:
git push            # push to the connected branch
# platform detects the app, builds a container,
# injects env vars, runs it, gives it a URL and HTTPS
</code></pre>
<p>What I like about this generation of platforms is that they collapse the app <em>and</em> its dependencies into one place. On Railway you can provision your database right next to your app and wire them together in the dashboard, so the <a href="https://ma-x.im/blog/react-playbook-database-turso">Turso-or-Postgres decision from the last article</a> and the hosting decision stop being two separate projects. The whole backend — process, database, environment — lives in one connected picture instead of a pile of services you glued together by hand. For a solo developer or a small team, that consolidation is the actual product.</p>
<p>I&#39;m not going to crown one winner. Railway is excellent for full-stack apps that want a database and a server in one tidy place; Fly.io leans toward running close to users at the edge; Vercel is the smoothest possible home for a Next.js app specifically. The right pick depends on your stack, and the honest advice is to match the platform to the shape of what you built, not to the loudest brand.</p>
<h2>Environment Variables: The One Thing to Get Right</h2>
<p>If there&#39;s a single operational skill that separates a smooth deploy from a painful one, it&#39;s handling configuration correctly — and it&#39;s where security quietly rides along.</p>
<p>The rule is simple and non-negotiable: <strong>secrets go in the platform&#39;s environment variables, never in your code, never in your repo.</strong> Your database URL, your auth tokens, your API keys — those live in Railway&#39;s (or whoever&#39;s) settings, injected into the process at runtime.</p>
<p>And there&#39;s a React-specific trap worth calling out, because it burns people. Anything your frontend bundle can read is public — it ships to the browser, where anyone can open it and look. Frameworks make this explicit with prefixes: a variable exposed to the client is deliberately marked as such.</p>
<pre><code class="language-bash"># Safe on the server only — never reaches the browser:
DATABASE_URL=...
AUTH_SECRET=...

# Deliberately public — bundled into the client, so treat as visible:
NEXT_PUBLIC_ANALYTICS_ID=...
VITE_PUBLIC_APP_URL=...
</code></pre>
<p>That prefix is a security boundary, not a naming convention. A secret with a public prefix is a secret you&#39;ve published. This connects straight back to the <a href="https://ma-x.im/blog/react-playbook-authentication">authentication article</a>: the same discipline of knowing what the browser can and cannot see applies to every environment variable you set. Get this one habit right and you&#39;ve avoided the most common way small teams leak credentials.</p>
<h2>Where the Magic Stops</h2>
<p>I&#39;ve spent this article celebrating how easy hosting got, so let me be honest about where the smooth part ends, because pretending it doesn&#39;t is how people get surprised.</p>
<p>Auto-scaling is real, but it isn&#39;t free or infinite — traffic spikes cost money, and a runaway process or a bad loop can run up a bill while you sleep. Cold starts are real on serverless tiers: a function that hasn&#39;t run in a while takes a beat to wake, and for some apps that latency matters. Databases have connection limits that serverless functions, which spin up many instances, can exhaust in ways that surprise you. And &quot;it deployed&quot; is not &quot;it works&quot; — you still own monitoring, logging, and knowing when production is unhappy.</p>
<p>None of this brings back the week-long deploy. It just means the modern platform moved your attention from <em>how do I ship this</em> to <em>how does this behave once it&#39;s shipped</em>, which is a much better problem to have. The floor got higher. The ceiling is still yours to worry about.</p>
<h2>The Takeaway</h2>
<p>Boil it down:</p>
<ul>
<li><strong>Know what you&#39;re deploying.</strong> Static site or running server — that single fact chooses your host.</li>
<li><strong>If it can be static, let it be static.</strong> The cheapest, most durable option is the one with nothing running.</li>
<li><strong>For full-stack, use a platform that consolidates app and database.</strong> Railway, Render, Fly.io — a git push to a live process, database next door.</li>
<li><strong>Secrets in env vars, and respect the public prefix.</strong> It&#39;s a security boundary, not a style choice.</li>
<li><strong>The magic stops at runtime behavior.</strong> Scaling cost, cold starts, connection limits, monitoring — still yours.</li>
</ul>
<p>I started this series&#39; thread on infrastructure by admitting the database used to scare me. Hosting used to scare me for the same reason — it looked like a separate profession guarding the door to production. It isn&#39;t anymore. The tools compressed a week of DevOps into a git push, and that didn&#39;t just save time; it moved the power to ship real software into the hands of individual developers. You can build the app, store its data, and put it in front of the world, alone, in an afternoon. That&#39;s genuinely new, and it&#39;s worth not taking for granted.</p>
<p>The app is built, stored, and deployed. Now we make sure it stays working — the next article is about <a href="https://ma-x.im/blog/react-playbook-testing">testing in React</a>: the kinds of tests, the pain each one actually removes, and how to test without drowning in brittle suites.</p>
<p>If you&#39;ve got an app that&#39;s stuck at the &quot;how do I actually put this online&quot; step, that used to be a wall and now it&#39;s a git push — tell me what you built and I&#39;ll point you at where it should live.</p>
]]></content:encoded>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Hosting</category>
      <category>Deployment</category>
      <category>Railway</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Databases for React Developers: What Serverless Actually Changed</title>
      <link>https://ma-x.im/blog/react-playbook-database-turso</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-database-turso</guid>
      <description>&quot;Add a database&quot; used to mean provisioning a server, managing connections, and running migrations by hand. Serverless quietly rewrote that whole sentence. Here&apos;s what a React developer actually needs to know about picking and using a database in 2026.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-database-turso/cover.png" alt="Databases for React Developers: What Serverless Actually Changed" /></p>
<p>There&#39;s a specific fear a lot of frontend developers have, and I had it too: the database is where I&#39;m not supposed to go. Tables, indexes, connection pools, migrations, the DBA who guards it all — it reads like someone else&#39;s territory, and the safe move is to stay in the browser where I belong.</p>
<p>That fear made sense a decade ago. It doesn&#39;t anymore, and holding onto it now quietly caps what you can build alone. Because &quot;add a database&quot; used to be a genuinely heavy sentence — provision a server, keep it running, manage a pool of connections, hand-run migrations, worry about it at 3am. Serverless rewrote every clause of that sentence, and most of the weight is just gone.</p>
<p>So this article is the database explainer I wish someone had handed me when I still thought data storage was off-limits. Not how to be a DBA — how to be a React developer who can pick a database, reason about it, and ship a full app without asking permission.</p>
<h2>The Fear Was Really About Operations</h2>
<p>Here&#39;s the thing I got wrong for years: my fear of databases wasn&#39;t about SQL. SQL is learnable in a weekend. It was about <em>operations</em> — the running, scaling, backing-up, not-losing-everyone&#39;s-data part. That&#39;s the genuinely hard, genuinely scary part, and it&#39;s exactly the part serverless takes off your plate.</p>
<p>A serverless database is one you don&#39;t run. No server to provision, no connection pool to babysit, no capacity to plan. It scales when you&#39;re busy, costs almost nothing when you&#39;re idle, and someone else owns the 3am pager. What&#39;s left for you is the part that was never scary: describing your data and querying it.</p>
<p>Once I understood that the wall was operational, not intellectual, databases stopped being forbidden and started being just another tool I could reach for. That reframe is the whole point of this article.</p>
<h2>The Types, Quickly and Honestly</h2>
<p>You need a rough map before names mean anything. There are more categories than this, but for building a React app, three matter.</p>
<p><strong>Relational (SQL)</strong> — Postgres, MySQL, SQLite. Data in tables with defined relationships. This is the default, and it should be. It&#39;s proven, it&#39;s flexible, and the guarantees are strong. When you don&#39;t have a specific reason to pick something else, pick this.</p>
<p><strong>Document (NoSQL)</strong> — MongoDB, Firestore. Data as flexible JSON-ish documents, no rigid schema up front. Genuinely useful when your data is loosely structured or you&#39;re moving fast before the shape settles. My honest take: teams reach for it to &quot;avoid schemas&quot; and later discover the schema didn&#39;t disappear, it just moved into their application code where the database can&#39;t help enforce it.</p>
<p><strong>Key-value</strong> — Redis. Dead-simple pairs, blazing fast, usually a <em>complement</em> to a real database for caching and sessions, not your source of truth.</p>
<p>If you&#39;re building a typical app and unsure, the boring answer is right: a relational database. The interesting question in 2026 isn&#39;t SQL versus NoSQL — it&#39;s what serverless did to the relational option.</p>
<h2>Why SQLite Came Back</h2>
<p>For years SQLite was filed under &quot;the little embedded database&quot; — great for a phone app or a local file, not something you&#39;d run a real web product on. That mental filing is now out of date, and it&#39;s worth understanding why, because it&#39;s the shift this whole article turns on.</p>
<p>SQLite is spectacularly simple and fast — the entire database is a file. What it historically lacked was a way to be that simple <em>and</em> live in the cloud serving a distributed app. That&#39;s the gap platforms like <strong>Turso</strong> fill: SQLite&#39;s simplicity, rebuilt for the edge, replicated close to your users, with the operational weight removed.</p>
<pre><code class="language-ts">import { createClient } from &#39;@libsql/client&#39;;

const db = createClient({
  url: process.env.DATABASE_URL!,
  authToken: process.env.DATABASE_AUTH_TOKEN,
});

// This is the whole &quot;connect to your production database&quot; story.
const policies = await db.execute(&#39;SELECT * FROM policies WHERE user_id = ?&#39;, [userId]);
</code></pre>
<p>Look at what isn&#39;t there. No pool configuration, no connection lifecycle, no server to have provisioned first. A URL and a token, and you&#39;re talking to a replicated production database. The thing that scared me — the operations — has been compressed into two environment variables. That&#39;s the serverless shift made concrete.</p>
<p>I&#39;m not claiming Turso is the only right answer; serverless Postgres options like Neon do the same trick for the Postgres world, and if your heart is set on Postgres, reach for one of those instead. The point isn&#39;t the specific vendor. It&#39;s that the operational barrier that made databases feel off-limits has genuinely fallen, whichever ecosystem you pick.</p>
<h2>Don&#39;t Write SQL Strings by Hand — Use an ORM</h2>
<p>The one place I&#39;ll push you toward a specific practice: put a typed query layer between your React-adjacent code and the raw database. In the TypeScript world that means an ORM like <strong>Drizzle</strong> or <strong>Prisma</strong>, and the reason is the same thread running through this entire series — types.</p>
<p>Raw SQL strings are stringly-typed. A typo in a column name is a runtime error you find in production. A typed ORM turns that into a compile error you find while typing.</p>
<pre><code class="language-ts">import { drizzle } from &#39;drizzle-orm/libsql&#39;;
import { eq } from &#39;drizzle-orm&#39;;

const db = drizzle(client);

// Fully typed: &#39;policies.userId&#39; is checked, the result is typed,
// a renamed column breaks the build, not production.
const rows = await db.select().from(policies).where(eq(policies.userId, userId));
//    ^? Policy[] — inferred from the schema
</code></pre>
<p>This should feel deeply familiar by now. It&#39;s the exact move from the <a href="https://ma-x.im/blog/react-playbook-backend">tRPC and server-functions articles</a>: define the shape once, let the compiler carry it everywhere. Your database schema becomes a TypeScript source of truth, the same types flow from the table into your query into your <a href="https://ma-x.im/blog/react-playbook-data-fetching">TanStack Query</a> cache and finally into your component. One definition, enforced end to end, from the database row to the rendered pixel. That&#39;s the coherence this series keeps chasing, now extended all the way down to storage.</p>
<h2>Migrations Aren&#39;t Scary Either</h2>
<p>The last piece of the old fear: migrations — changing your schema without breaking the running app. This used to be hand-written SQL run carefully against production, and it&#39;s where &quot;I might delete everything&quot; lives.</p>
<p>Modern ORMs largely defuse it. You change your schema definition in TypeScript, and the tool generates the migration for you.</p>
<pre><code class="language-bash"># Change the schema in code, then:
npx drizzle-kit generate   # generates the migration from the diff
npx drizzle-kit migrate    # applies it
</code></pre>
<p>You still have to <em>think</em> — a migration that drops a column is still destructive, and no tool saves you from a bad decision. But the mechanical, error-prone part of hand-writing schema changes is handled, and the migration lives in your repo as a reviewable, version-controlled file instead of a command someone typed into a production shell and forgot.</p>
<h2>What a React Developer Should Actually Take Away</h2>
<p>Strip it down:</p>
<ul>
<li><strong>The wall was operational, not intellectual.</strong> Serverless removed the running-and-scaling part that was actually hard.</li>
<li><strong>Default to relational.</strong> Reach for document or key-value only when you have a specific reason.</li>
<li><strong>SQLite is a real option now.</strong> Turso (or serverless Postgres like Neon) gives you production data storage behind a URL and a token.</li>
<li><strong>Use a typed ORM.</strong> Drizzle or Prisma make your schema a TypeScript source of truth, and the types flow all the way to your components.</li>
<li><strong>Migrations are generated, not hand-written</strong> — but you still own the judgment.</li>
</ul>
<p>I spent too long treating the database as someone else&#39;s job, and all it did was make me dependent on someone else to ship anything real. The serverless shift didn&#39;t just make databases easier — it made them <em>mine</em>, and yours. You don&#39;t need a DBA and a provisioning ticket to store data anymore. You need a schema, a typed client, and the willingness to walk through a wall that isn&#39;t there. That, more than any single tool, is what changed.</p>
<p>With storage handled, the app needs to actually run somewhere the world can reach it. The next article is about <a href="https://ma-x.im/blog/react-playbook-hosting-railway">hosting a React app</a> — how modern platforms turned deployment from a DevOps project into a git push, and where the limits still bite.</p>
<p>If you&#39;re sitting on an app idea and the database is the part that&#39;s stopping you, that&#39;s the exact fear I&#39;m describing — tell me what you&#39;re trying to store and I&#39;ll help you pick where it goes.</p>
]]></content:encoded>
      <pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Database</category>
      <category>Turso</category>
      <category>SQLite</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>SSR in React: What TanStack Start Actually Changes</title>
      <link>https://ma-x.im/blog/react-playbook-ssr-tanstack-start</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-ssr-tanstack-start</guid>
      <description>Server rendering isn&apos;t a performance checkbox — it collapses the wall between frontend and backend into one framework. Here&apos;s what SSR really buys you, why the client/server line stops being where you think it is, and where TanStack Start fits next to Next.js.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-ssr-tanstack-start/cover.png" alt="SSR in React: What TanStack Start Actually Changes" /></p>
<p>For most of this series I&#39;ve drawn a clean line: React runs in the browser, the backend runs on a server, and the <a href="https://ma-x.im/blog/react-playbook-backend">contract between them</a> is where the interesting decisions live. That line is useful. It&#39;s also about to dissolve.</p>
<p>Server-side rendering is usually sold as a performance trick — render HTML on the server so the first paint is faster and Google can read your page. That&#39;s true, and it&#39;s the least interesting thing about it. The real change is architectural: SSR takes the wall between &quot;frontend&quot; and &quot;backend&quot; and turns it into a seam <em>inside a single framework</em>. Suddenly some of your React code runs on a server, some runs in a browser, and the hardest part is knowing which is which — and why it matters.</p>
<p>So this article isn&#39;t a list of SSR benefits. It&#39;s about what actually shifts in how you think when rendering moves to the server, and where TanStack Start lands in a world Next.js has owned for years.</p>
<h2>What SSR Actually Buys You</h2>
<p>Let me be fair to the standard pitch first, because the benefits are real. Server rendering gives you a faster first contentful paint — the user sees content before a single kilobyte of JavaScript executes. It gives you SEO that actually works, because crawlers get real HTML instead of an empty <code>&lt;div id=&quot;root&quot;&gt;</code>. And it gives you a better story on slow devices, because the server does the heavy lifting once instead of every phone doing it again.</p>
<p>If that were all SSR did, it&#39;d be a performance optimization you turn on and forget. But turning it on changes the shape of your code, and that&#39;s the part nobody warns you about until you hit it.</p>
<h2>The Line Moves, and You Have to Know Where</h2>
<p>Here&#39;s the thing that trips everyone up, and it&#39;s worth slowing down on.</p>
<p>In a pure client-side app, all your code runs in one place: the browser. <code>window</code> exists. <code>localStorage</code> exists. Every component runs after the user&#39;s device has loaded it. Simple mental model.</p>
<p>The moment you add SSR, your components can run in <em>two</em> environments — once on the server to produce the initial HTML, and again (or partially) in the browser to make it interactive. And the server doesn&#39;t have <code>window</code>. It doesn&#39;t have <code>localStorage</code>. Code that assumed the browser now runs somewhere the browser&#39;s globals don&#39;t exist.</p>
<pre><code class="language-tsx">function Sidebar() {
  // Runs on the server first — where &#39;window&#39; is undefined.
  // This throws during SSR, and the error is confusing the first time.
  const collapsed = window.localStorage.getItem(&#39;sidebar-collapsed&#39;);
  return collapsed ? &lt;Collapsed /&gt; : &lt;Expanded /&gt;;
}
</code></pre>
<p>That crashes on the server, and the first time it happens the error message sends you on a goose chase, because it&#39;s failing in a place you didn&#39;t know your component ran. This is the tax of SSR: you now have to know, for every piece of code, <em>where it executes</em>. Browser-only work — reading <code>localStorage</code>, touching the DOM, using <code>window</code> — has to be deferred to after hydration, usually in an effect. It&#39;s not hard once you have the mental model. It&#39;s brutal before you do.</p>
<blockquote>
<p>With SSR, &quot;where does this run?&quot; stops being a trivia question and becomes the question. Half of SSR debugging is just answering it correctly.</p>
</blockquote>
<p>That&#39;s the real cost, and no framework fully removes it — it&#39;s inherent to running the same code in two places. What a good framework does is make the boundary <em>visible</em> instead of something you discover by crashing.</p>
<h2>Hydration: The Weird Middle Step</h2>
<p>There&#39;s a moment between &quot;server sent HTML&quot; and &quot;app is interactive&quot; that deserves a name, because it&#39;s the source of the strangest SSR bugs: hydration.</p>
<p>The server sends fully-formed HTML so the user sees content immediately. But that HTML is inert — no event handlers, no state. Then the JavaScript loads and React &quot;hydrates&quot; it: it walks the server-rendered DOM and attaches all the interactivity, expecting the DOM it finds to match the DOM it would have rendered.</p>
<p>When those two don&#39;t match — because you rendered a timestamp, or a random value, or something that read <code>window</code> — you get a hydration mismatch, and React complains. The fix is conceptual, not mechanical: anything that differs between server and client has to wait until after hydration. Once you internalize that server and client must agree on the first render, the whole class of hydration bugs stops being mysterious and starts being obvious.</p>
<h2>Where TanStack Start Comes In</h2>
<p>Everything so far is true of any SSR framework. So why TanStack Start, in a world where Next.js is the default answer?</p>
<p>Because of what this series has already built. If you&#39;ve been following along, you&#39;re using <a href="https://ma-x.im/blog/react-playbook-data-fetching">TanStack Query</a> for server state and <a href="https://ma-x.im/blog/react-playbook-tanstack-router">TanStack Router</a> for type-safe routing. TanStack Start is a full-stack framework built <em>on that same router</em> — so SSR, server functions, and data loading arrive as a natural extension of tools you already use, not a new paradigm you adopt wholesale.</p>
<p>The piece that matters most is <strong>server functions</strong> — typed functions you write once and call from the client, where the network boundary between them is handled for you.</p>
<pre><code class="language-ts">import { createServerFn } from &#39;@tanstack/start&#39;;

const getPolicy = createServerFn(&#39;GET&#39;, async (id: number) =&gt; {
  // This body runs ONLY on the server — DB access is safe here.
  return db.policy.findUnique({ where: { id } });
});

// Called from a component; the type flows across the boundary,
// and &#39;db&#39; never ships to the browser.
const policy = await getPolicy(42);
//    ^? Policy — inferred end to end
</code></pre>
<p>If that feels familiar, it should — it&#39;s the <a href="https://ma-x.im/blog/react-playbook-backend">tRPC idea from the backend article</a>, folded directly into the framework. You write a function that runs on the server, call it from the client with full type safety, and the framework draws the network line for you. The <code>db</code> import never reaches the browser. The type reaches everything. That coherence — one router, one type system, server and client speaking the same language — is the reason Start fits this series so naturally.</p>
<h2>The Honest Comparison with Next.js</h2>
<p>I&#39;m not going to pretend TanStack Start dethrones Next.js. It doesn&#39;t, and picking it over Next.js is a real trade-off, not a free upgrade.</p>
<p>Next.js is mature, enormous, and battle-tested at every scale, with the deepest ecosystem in React and a hosting story that&#39;s effectively frictionless on Vercel. Its App Router and React Server Components are a genuinely different, powerful model. If your team wants the safest, most-documented, most-hireable-for choice, Next.js is it, and I&#39;d never argue someone out of it on the merits.</p>
<p>What TanStack Start offers is a different center of gravity. It&#39;s router-first and type-obsessed, and if your app is already built on TanStack&#39;s pieces, Start is the option where <em>everything agrees</em> — the same router types flow from your routes into your loaders into your server functions with no seams. Next.js asks you to adopt its model of the world; Start extends the model you already chose. For the kind of deeply-typed, client-rich application this series keeps building, that consistency is worth a lot. For a content site that wants RSC and edge rendering out of the box, Next.js is probably still the sharper tool.</p>
<p>That&#39;s the honest split: Next.js for maturity and reach, Start for coherence with a TanStack-native stack. Neither is a mistake.</p>
<h2>The Real Takeaway</h2>
<p>Strip away the framework names and here&#39;s what SSR actually asks of you:</p>
<ul>
<li><strong>Know where every piece of code runs.</strong> Server or browser is now a question you answer constantly, not never.</li>
<li><strong>Defer browser-only work past hydration.</strong> <code>window</code>, <code>localStorage</code>, the DOM — none of it exists during the server render.</li>
<li><strong>Make server and client agree on the first render.</strong> Mismatches are the price of getting this wrong.</li>
<li><strong>Let the framework draw the boundary.</strong> Server functions turn &quot;which side does this run on&quot; from a guess into a typed contract.</li>
</ul>
<p>I opened by saying the line between frontend and backend was about to dissolve, and this is what I meant. SSR doesn&#39;t move your code to the server — it makes &quot;the server&quot; and &quot;the browser&quot; two places your <em>same</em> code lives, and the skill it demands is holding both in your head at once. That&#39;s harder than it sounds and more valuable than it looks. The developers I trust most with a large React app aren&#39;t the ones who memorized a framework. They&#39;re the ones who always know where their code is running.</p>
<p>With rendering settled, the app needs somewhere to keep its data. The next article is about <a href="https://ma-x.im/blog/react-playbook-database-turso">databases for React developers</a> — the kinds, the trade-offs, and why the modern serverless options change what &quot;add a database&quot; even means.</p>
<p>If you&#39;ve fought a hydration mismatch that made no sense, or you&#39;re weighing Start against Next.js for a real project, I&#39;d like to hear the specifics — those decisions are all about your exact stack, and that&#39;s where the interesting part lives.</p>
]]></content:encoded>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>SSR</category>
      <category>TanStack Start</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>The Backend a React Developer Actually Needs to Understand</title>
      <link>https://ma-x.im/blog/react-playbook-backend</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-backend</guid>
      <description>You don&apos;t have to write the server. But the shape of the contract between your React app and the backend — REST, tRPC, or a BFF — decides how much of your frontend is real work versus glue code. Here&apos;s the part of the backend that&apos;s actually yours.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-backend/cover.png" alt="The Backend a React Developer Actually Needs to Understand" /></p>
<p>&quot;That&#39;s a backend concern&quot; is one of the most expensive sentences a frontend developer can say.</p>
<p>I understand the instinct. There&#39;s a clean line in most people&#39;s heads: the backend team owns the server, we own the browser, and the API is the wall between us. Someone hands you an endpoint, you fetch from it, you render. Not your problem what happens on the other side.</p>
<p>Except the shape of that wall decides how much of your job is real work versus glue code. I&#39;ve spent weeks of my life writing frontend that existed only to paper over an awkward API — flattening nested responses, stitching three calls into one screen, re-deriving fields the server could have just sent. That&#39;s not frontend work. That&#39;s tax, paid in your codebase, because of a decision made on the other side of a wall you decided wasn&#39;t yours.</p>
<p>So this article isn&#39;t about writing servers. It&#39;s about the part of the backend that&#39;s genuinely yours as a React developer: the contract. How you talk to the server — REST, tRPC, or a BFF — shapes your frontend more than most of us are willing to admit.</p>
<h2>The Contract Is the Product</h2>
<p>Here&#39;s the reframe that changed how I work: from the frontend, the backend <em>is</em> its API. You don&#39;t experience the database, the services, the queues. You experience the contract — the shape of the requests and responses. And a good contract makes the frontend small, while a bad one makes it a translation layer.</p>
<p>That&#39;s the lens for everything below. Not &quot;which technology is trendy,&quot; but &quot;which contract leaves the least incidental work in my React code.&quot; Because every gap between what the server sends and what the UI needs gets filled by <em>you</em>, in components that should be about rendering and instead are about reshaping data.</p>
<h2>REST: The Default, and Its Real Cost</h2>
<p>REST is the lingua franca. Everything speaks it, every tool supports it, and for a lot of applications it&#39;s completely fine. When someone hands me a well-designed REST API — sensible resources, predictable shapes, the fields I need where I expect them — I have no complaints. It pairs perfectly with <a href="https://ma-x.im/blog/react-playbook-data-fetching">TanStack Query</a>, which was practically built for the REST request/response model.</p>
<p>The costs of REST are real but they&#39;re not &quot;REST is bad.&quot; They&#39;re two specific frictions.</p>
<p>The first is <strong>over- and under-fetching</strong>. An endpoint returns a fixed shape. Sometimes that&#39;s more than your screen needs, so you ship kilobytes you throw away. More often it&#39;s less than you need, so you make a second call, then a third, and now one screen is a waterfall of requests you have to orchestrate and cache.</p>
<p>The second, and the one that actually costs me time, is <strong>types</strong>. A REST response is JSON — untyped by nature. On the client it arrives as <code>any</code> unless you do something about it. Which means the boundary between &quot;typed React app&quot; and &quot;untyped network&quot; runs right through your data layer, and you spend effort re-establishing types the backend already knew.</p>
<pre><code class="language-ts">// The gap, made concrete:
const res = await fetch(&#39;/api/policies/42&#39;);
const policy = await res.json(); // any — the types stop here

// so you rebuild what the server already knew:
const policy = policySchema.parse(await res.json()); // Zod, by hand
</code></pre>
<p>That <code>policySchema</code> is you, manually, re-declaring a shape the backend already has in its own code. It works — I do it, and validating at the boundary with Zod is genuinely good practice. But notice what it is: the same truth, written twice, on two sides of the wall, kept in sync by discipline. Hold that thought, because the next option deletes it.</p>
<h2>tRPC: When You Own Both Sides</h2>
<p>If your backend is TypeScript, and especially if it&#39;s the same repo or monorepo as your frontend, tRPC changes the entire equation. It gives you end-to-end type safety with no schema, no code generation, no manual sync.</p>
<p>You define a procedure on the server, and the client just <em>knows</em> its types — inputs, outputs, everything — inferred straight across the boundary.</p>
<pre><code class="language-ts">// server: the procedure
const policyRouter = router({
  byId: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(({ input }) =&gt; getPolicy(input.id)), // returns Policy
});

// client: fully typed, no codegen, no fetch wiring
const policy = await trpc.policy.byId.query({ id: 42 });
//    ^? Policy — inferred end to end
</code></pre>
<p>Look at what disappeared. No <code>policySchema</code> written twice. No <code>any</code> at the network boundary. Rename a field on the server and your React code fails to compile <em>before</em> you run it — the contract enforces itself. This is the same instinct the whole series keeps returning to: one source of truth, let the compiler carry it. tRPC just extends that line across the network, which is the one place REST couldn&#39;t.</p>
<p>I&#39;ll be honest about the constraints, because they&#39;re firm. tRPC needs TypeScript on both ends, and it works best when the same team owns both. It&#39;s not for a public API consumed by clients you don&#39;t control, and it&#39;s not what you reach for when a separate backend team ships you REST or GraphQL. But for the shape this series keeps building — a TypeScript frontend and backend under one roof — it removes an entire category of busywork. When I control both sides, it&#39;s my default, for the same reason I pick every other tool in this series: it makes the frontend smaller.</p>
<h2>GraphQL: The Over/Under-Fetching Answer</h2>
<p>GraphQL deserves a fair mention, because it targets REST&#39;s first friction directly. The client asks for exactly the fields it needs — no more, no less — in one request, even across what would have been several REST resources. For a complex UI assembling data from many places, that&#39;s a real answer to the waterfall problem, and the typed schema helps on the client too.</p>
<p>I reach for it less than I used to, and it&#39;s a cost-benefit call, not a dismissal. GraphQL brings its own server, its own caching model, its own operational weight. On a large product with many clients and genuinely graph-shaped data, that weight pays for itself. On the mid-size TypeScript app this series targets, tRPC gets me most of the type-safety win for a fraction of the machinery. Right tool, right scale.</p>
<h2>The BFF: A Backend That Works for the Frontend</h2>
<p>There&#39;s one pattern worth knowing by name, because at some point you&#39;ll need it and it helps to know it has one: the <strong>Backend for Frontend</strong>.</p>
<p>The situation is familiar. You don&#39;t control the backend. It&#39;s a separate team, or several, exposing APIs designed for their convenience, not your screen&#39;s. Your dashboard needs data from three services in shapes that don&#39;t match what any of them return. The naive fix is to do all that stitching in React — three calls, merge, reshape, re-derive — and now your components are a data-integration layer wearing a UI costume.</p>
<p>A BFF is a thin server layer that belongs to the frontend and sits between your React app and those messy upstream services. Its whole job is to talk to them and hand your UI exactly the shape it wants, in one call.</p>
<pre><code class="language-ts">// BFF endpoint: one call for the frontend,
// three upstream calls hidden behind it
async function getDashboard(userId: string) {
  const [profile, policies, claims] = await Promise.all([
    profileService.get(userId),
    policyService.listFor(userId),
    claimService.recentFor(userId),
  ]);
  return shapeForDashboard(profile, policies, claims); // exactly what the UI renders
}
</code></pre>
<p>The reason I like the BFF isn&#39;t just tidiness. It moves the stitching to where it belongs. Reshaping data, aggregating services, adapting an awkward upstream contract — that&#39;s backend work, and doing it in a Node BFF is cheaper, more testable, and more cacheable than doing it in React components. Your frontend goes back to rendering. And if you&#39;re already in a <a href="https://ma-x.im/blog/react-playbook-tanstack-router">TanStack Start</a> or Next.js world, you have a natural place to put a BFF without standing up a whole separate service.</p>
<p>The through-line to the whole article is right here: whenever you find your React code reshaping data, ask whether that reshaping belongs on the server instead. Usually it does.</p>
<h2>How I Actually Decide</h2>
<p>Strip it to the decision:</p>
<ul>
<li><strong>A separate team ships you REST or GraphQL</strong> → consume it with TanStack Query, validate at the boundary with Zod, and consider a BFF the moment your components start stitching.</li>
<li><strong>You own a TypeScript backend, same repo or org</strong> → tRPC. End-to-end types, no manual sync, the frontend stays small.</li>
<li><strong>Complex graph-shaped data, many clients, real scale</strong> → GraphQL earns its weight.</li>
<li><strong>You don&#39;t own the upstream and the shapes fight your UI</strong> → a BFF, so the reshaping lives on the server where it&#39;s cheap.</li>
</ul>
<p>None of this requires you to write a real backend. It requires you to have an opinion about the contract, and to push on it when it&#39;s making your frontend do work it shouldn&#39;t. That&#39;s the part that was always yours.</p>
<p>I used to think staying out of backend decisions kept my work clean. It did the opposite — it let other people&#39;s convenience become my complexity. The most leverage I&#39;ve found as a frontend developer isn&#39;t a faster component or a cleverer hook. It&#39;s a better contract, argued for early, that makes half the frontend code simply unnecessary. You don&#39;t have to build the server. You do have to care what it sends you.</p>
<p>The next article goes one layer deeper into that same server: <a href="https://ma-x.im/blog/react-playbook-ssr-tanstack-start">SSR and React with TanStack Start</a> — where &quot;frontend&quot; and &quot;backend&quot; stop being two places and start being one framework, and what that does to everything we&#39;ve built so far.</p>
<p>If you&#39;re staring at a React codebase that&#39;s mostly reshaping awkward API responses, that&#39;s usually a contract problem wearing a frontend costume — tell me what the API looks like and I&#39;ll tell you where I&#39;d put the seam.</p>
]]></content:encoded>
      <pubDate>Thu, 16 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Backend</category>
      <category>tRPC</category>
      <category>API Design</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Authentication in React: Buy It, and Where the Line Really Is</title>
      <link>https://ma-x.im/blog/react-playbook-authentication</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-authentication</guid>
      <description>Auth looks like a login form and turns out to be an architecture decision. Where do tokens live, who guards a route, what happens when the session dies mid-request? Here&apos;s why I buy auth instead of building it, and the parts that stay yours no matter what you buy.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-authentication/cover.png" alt="Authentication in React: Buy It, and Where the Line Really Is" /></p>
<p>Authentication always starts as a login form. Two fields and a button. You could build that in an afternoon, and that afternoon is exactly the trap.</p>
<p>Because the form isn&#39;t the work. The work is everything the form implies. Where does the token live once the server hands it back? How does every subsequent request prove who you are? What happens when the token expires halfway through a multi-step action the user has been filling out for ten minutes? Who decides that this route is off-limits to this person, and where does that decision live so it can&#39;t be bypassed? None of that is visible in the login form, and all of it is auth.</p>
<p>So this article isn&#39;t about building a login screen. It&#39;s about the decision underneath it — buy versus build — and about the parts that stay your responsibility no matter which way you go. Because there&#39;s a security floor here that a nice-looking form quietly hides, and getting it wrong doesn&#39;t throw an error. It just leaves a door open.</p>
<h2>The Honest Default: Don&#39;t Build It</h2>
<p>I&#39;ll put my conclusion first, because I hold it strongly: for almost every application, you should not roll your own authentication.</p>
<p>This is not the usual &quot;don&#39;t reinvent the wheel&quot; reflex. Auth is a category where the cost of a subtle mistake is not a bug — it&#39;s a breach. Password hashing done slightly wrong. A token stored somewhere a script can read it. A session that doesn&#39;t actually invalidate on logout. A password-reset flow with a timing leak. These aren&#39;t exotic; they&#39;re the standard ways homegrown auth fails, and you usually find out about them from someone you&#39;d rather not hear from.</p>
<p>Providers like Auth0, Clerk, and Supabase Auth — and the framework-native options like NextAuth/Auth.js — exist because a lot of very focused people have already made those mistakes so you don&#39;t have to. They handle the hashing, the token lifecycle, the reset flows, the MFA, the OAuth dance with Google and GitHub. Buying that isn&#39;t laziness. It&#39;s routing your effort to where your product is actually different, which is almost never &quot;we hash passwords in a special way.&quot;</p>
<p>So my real advice is narrow: <strong>use a provider, and spend your energy integrating it correctly</strong> — because the integration is where the interesting decisions still live, and where you can still get it wrong.</p>
<h2>Where Tokens Live Is the Whole Game</h2>
<p>Here&#39;s the decision that matters most on the client, and the one people get wrong most often: once you&#39;re authenticated, where does the credential live in the browser?</p>
<p>The tempting answer is <code>localStorage</code>. It&#39;s easy, it persists across tabs and reloads, and the token is right there when you need it.</p>
<p>It&#39;s also readable by any JavaScript running on your page. That&#39;s the problem. If an attacker gets a script onto your site — a compromised dependency, a bad ad, an XSS hole — <code>localStorage</code> hands them the token directly. This is the single most common serious mistake I see in hand-rolled React auth, and it looks completely fine until the day it isn&#39;t.</p>
<p>The safer model is an <strong>httpOnly cookie</strong>: the server sets it, the browser sends it automatically, and JavaScript literally cannot read it. That removes the entire class of &quot;a script stole the token&quot; attacks. The trade-off you take on in return is CSRF — because the cookie is sent automatically, you now need CSRF protection — but that&#39;s a well-understood problem with standard defenses, and it&#39;s a better problem to own than token theft.</p>
<pre><code class="language-ts">// The shape of the good answer, conceptually:
// - server sets an httpOnly, Secure, SameSite cookie on login
// - the browser attaches it to every request automatically
// - the React app never sees, stores, or touches the raw token
// - CSRF protection guards the state-changing requests
</code></pre>
<p>Notice what this does to your React code: the app stops managing tokens at all. It doesn&#39;t read them, store them, or attach them to requests. That&#39;s the goal. The less your frontend knows about the raw credential, the fewer ways it can leak one. A good provider integration leans exactly this way — and this is precisely the kind of thing you get for free by buying instead of building.</p>
<h2>Auth State Is Just State — Treat It That Way</h2>
<p>On the client, &quot;am I logged in, and who am I?&quot; is a piece of state like any other. The mistake is treating it as special and inventing a bespoke system for it, when the tools this series has already chosen handle it cleanly.</p>
<p>The current user is server state — it lives on the server, you fetch it, it can go stale. That makes it a <a href="https://ma-x.im/blog/react-playbook-data-fetching">TanStack Query</a> concern, not a hand-built context with manual loading flags.</p>
<pre><code class="language-tsx">function useCurrentUser() {
  return useQuery({
    queryKey: [&#39;currentUser&#39;],
    queryFn: fetchCurrentUser,
    staleTime: 5 * 60 * 1000,
    retry: false,
  });
}
</code></pre>
<p>Now the whole app has one honest source of truth for identity. Logging out is a cache invalidation. A 401 from any request can trigger a refetch that flips the user to logged-out everywhere at once. You didn&#39;t build an auth state machine — you reused the data layer you already have, and it behaves consistently because it&#39;s the same machinery as the rest of your data. That consistency is the point I keep coming back to in this series: identity isn&#39;t a special island, it&#39;s server state, so it uses the server-state tool.</p>
<h2>Guarding Routes: A Convenience, Not a Wall</h2>
<p>This is the part where a dangerous misconception hides, so I want to be blunt about it.</p>
<p>Protecting a route on the client — redirecting away from <code>/dashboard</code> when there&#39;s no user — is a <strong>UX feature, not a security control.</strong> With <a href="https://ma-x.im/blog/react-playbook-tanstack-router">TanStack Router</a> you can gate a route in its <code>beforeLoad</code>, and you should, because showing a logged-out person a broken dashboard is bad UX.</p>
<pre><code class="language-tsx">export const Route = createFileRoute(&#39;/dashboard&#39;)({
  beforeLoad: ({ context }) =&gt; {
    if (!context.auth.user) {
      throw redirect({ to: &#39;/login&#39; });
    }
  },
});
</code></pre>
<p>But understand exactly what that does and does not do. It stops a <em>browser</em> from <em>showing</em> a page. It does nothing to stop a <em>request</em> from <em>reaching your data</em>. Anyone can open dev tools, read your bundle, and call your API directly — the client-side guard is irrelevant to them.</p>
<blockquote>
<p>Client-side route guards decide what the UI shows. The server decides what the user can actually access. Only one of those is security.</p>
</blockquote>
<p>The real protection lives on the server: every endpoint validates the session and authorizes the specific action, every time, as if the client did not exist. The React route guard makes the app pleasant for honest users. The server check makes it safe from everyone else. If you only build one, build the server one — an app with server checks and no client guard is merely awkward; an app with client guards and no server checks is wide open.</p>
<h2>The Part No Provider Handles: The Expiry Moment</h2>
<p>Buy the provider, store the token safely, guard your routes and your server — and there&#39;s still one moment that stays yours, because it&#39;s a UX problem, not a security one: the session dies while the user is in the middle of something.</p>
<p>Tokens expire. The user was filling out a long form (the exact painful form from the <a href="https://ma-x.im/blog/react-playbook-form-libraries">forms article</a>), stepped away for coffee, came back, hit save — and the request comes back 401. What now?</p>
<p>This is where thoughtful apps separate from careless ones, and no library decides it for you. The naive answer is to hard-redirect to login and throw away everything they typed, which is how you make a user hate your product. The considered answer is a layer that catches the 401, attempts a silent token refresh if the provider supports it, retries the original request, and only if that genuinely fails preserves their work and prompts a re-login without discarding the form.</p>
<pre><code class="language-ts">// Conceptual interceptor, not a full implementation:
// 1. request returns 401
// 2. attempt a silent refresh (if the provider offers refresh tokens)
// 3. on success -&gt; retry the original request, user notices nothing
// 4. on failure -&gt; keep their unsaved work, prompt a gentle re-login
</code></pre>
<p>The provider hands you the refresh capability. What you do with it — how gracefully you handle the moment a session dies under a user&#39;s hands — is product design, and it&#39;s entirely yours. This is the through-line of the whole article: buying auth doesn&#39;t remove your judgment, it relocates it. You stop deciding how to hash a password and start deciding how it feels when a session expires. The second decision is the one your users actually experience.</p>
<h2>Where the Line Really Is</h2>
<p>So the buy-versus-build question has a cleaner answer than it looks:</p>
<ul>
<li><strong>Hashing, token issuance, OAuth, MFA, password resets</strong> → buy. This is the security floor, and the cost of building it wrong is a breach, not a bug.</li>
<li><strong>Where the token lives in the browser</strong> → yours, and httpOnly cookies over <code>localStorage</code> is close to non-negotiable.</li>
<li><strong>Auth state in the app</strong> → not special; it&#39;s server state, so it&#39;s a TanStack Query concern.</li>
<li><strong>Route guarding</strong> → client-side for UX, server-side for security, and never confuse the two.</li>
<li><strong>The expiry-mid-action moment</strong> → yours, and it&#39;s the difference between an app people trust and one they resent.</li>
</ul>
<p>I buy authentication, and I&#39;d tell you to as well — not because integrating it is trivial, but because buying it moves the hard part to where it belongs. The interesting work was never the login form. It&#39;s the token storage, the guard boundary, the graceful death of a session. That&#39;s the part that shapes the architecture, and that&#39;s the part that&#39;s still yours after you&#39;ve paid someone else for the rest.</p>
<p>The next article follows this thread straight down: <a href="https://ma-x.im/blog/react-playbook-backend">what a React developer actually needs to know about the backend</a> — REST versus tRPC versus a BFF — because how you talk to your server decides more about your frontend than most of us admit.</p>
<p>If you&#39;ve had a session expire under a user in a way that cost them real work — or if you&#39;re running <code>localStorage</code> tokens right now and want to talk through moving off them — reach out. That migration is one of the higher-leverage things you can do for an app&#39;s security, and I&#39;m happy to compare notes.</p>
]]></content:encoded>
      <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Authentication</category>
      <category>Security</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>The AI Agent Memory System I Built Was Broken — Here Is the Redesign</title>
      <link>https://ma-x.im/blog/ai-agent-memory-redesign</link>
      <guid isPermaLink="true">https://ma-x.im/blog/ai-agent-memory-redesign</guid>
      <description>Append-only markdown memory works great — until it doesn&apos;t. After watching the system degrade in production, our team redesigned it from scratch with strict principles: small, high-signal, controlled, and self-maintaining.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/ai-agent-memory-redesign/cover.png" alt="The AI Agent Memory System I Built Was Broken — Here Is the Redesign" /></p>
<p>A few weeks ago I wrote about <a href="https://ma-x.im/blog/give-your-ai-agent-a-memory">giving your AI agent a persistent memory</a> — a simple system of markdown files the agent reads and writes across sessions. I was excited about it. It worked. I rolled it out on my personal project, recommended it to colleagues, and eventually submitted a pull request adding the memory system to our main company repository.</p>
<p>That PR triggered something I did not expect.</p>
<p>A manager reached out and suggested organizing a meeting — not to approve or reject the PR, but because several people at the company had already been experimenting with similar ideas independently. Before the meeting even happened, there was a chat where people started sharing their experiences. Engineers who had been quietly running local memory systems of their own, not as a global company-wide thing, just something they had hacked together for themselves. They shared what worked and, more importantly, what did not.</p>
<p>What I heard in that chat changed how I think about this entirely.</p>
<h2>What Broke</h2>
<p>The first version of the system had one rule: append. Learned something? Append it. Fixed a bug? Append it. Session ended? Append a summary. Append, append, append.</p>
<p>This works fine for the first few days. Then the files get long. Then they get contradictory. Then they become a liability.</p>
<p>One story from the chat stuck with me. A colleague had been working in a long session — the kind where you get deep into something and lose track of time. The agent was faithfully logging each action to a sessions file, just as instructed. Then midway through, he asked it to do something simple. Small task. Nothing complicated.</p>
<p>The agent picked up the session logs, scanned the history, and concluded that several things from earlier in the session had not been completed yet. So it started doing them. All of them. On top of the actual task. Everything tangled together. The agent was trying to be helpful by using the context it had been given — and it made a mess precisely because of that context.</p>
<p>He rolled everything back and started a fresh session. Simple task, no history, done in two minutes.</p>
<p>That story captures the failure mode better than any metric I could give you. The agent did exactly what you told it to do. It dutifully loaded the history, applied it to the task, and produced chaos. The problem was not the agent. The problem was that we had built a system that grew without any mechanism for forgetting.</p>
<p>Here is what we also observed across different setups:</p>
<p><strong>Context explosion.</strong> <code>lessons.md</code> hit 200 lines in three weeks. The agent was loading all of it into context on every session. Most of it was stale or irrelevant to the current task. We were paying context budget for noise.</p>
<p><strong>Drift and contradictions.</strong> Decisions change. The agent would append a new decision without removing the old one. Now both exist. Which is current? Depends on where in the file the agent looks.</p>
<p><strong>Entropy wins.</strong> The longer a system runs without maintenance discipline, the worse it gets. Append-only systems drift toward entropy by design.</p>
<p>We realized the core mistake: <strong>we treated memory like a log, not like a knowledge base.</strong></p>
<h2>The Redesign Principles</h2>
<p>The meeting itself was candid in a way that work meetings rarely are. People with months of hands-on experience with these systems, and everyone had the same conclusion: the idea is right, but the implementation costs too much to maintain. The systems degraded. They required manual cleanup. They occasionally made things worse. The good news was that everyone had independently arrived at similar fixes — which meant the fixes were probably right.</p>
<p>The rewrite started with constraints rather than architecture. We asked: what properties does memory need to have to not degrade?</p>
<p><strong>Memory is not a log.</strong> Do not store what happened. Store what matters. A session where nothing changed — nothing gets written.</p>
<p><strong>If memory grows continuously, it is a bug.</strong> Not a feature, not normal accumulation. A symptom of missing update rules.</p>
<p><strong>Overwrite, do not append.</strong> When knowledge changes, replace the old entry. The file should represent current state, not a history of states.</p>
<p><strong>Load index first, fetch content lazily.</strong> The agent should never blindly load all memory into context. It should load a small index, decide what is relevant, then fetch only that.</p>
<p><strong>One source of truth per topic.</strong> No two entries should describe the same thing differently. If they do, one is wrong and should be removed.</p>
<h2>The New File Structure</h2>
<pre><code>.ai-memory/
  index.md          — What exists and when to load it
  state.md          — Current project state (short, always overwritten)
  decisions.md      — Architecture and product decisions (overwritten, not appended)
  facts.md          — Stable technical facts (rarely changes)
  tasks.md          — Active tasks only (no completed tasks)
</code></pre>
<p>Five files. No sessions folder. No history.</p>
<p>Notice what is gone: the sessions directory. We removed it. It was generating the most noise for the least value. If you need an audit trail, use git — that is what git is for. Memory is not git.</p>
<h3>index.md</h3>
<p>This is the only file the agent loads unconditionally at the start of a session. Everything else is fetched on demand.</p>
<pre><code class="language-markdown"># Memory Index

## Always load
- state.md — current project state, active focus

## Load when relevant
- decisions.md — when making architecture or tech stack decisions
- facts.md — when working with the API, data schema, or deployment
- tasks.md — when planning work or checking what is in progress

## Topics → files
- auth flow → facts.md#authentication
- API endpoints → facts.md#api
- styling rules → decisions.md#styling
- current sprint → tasks.md
</code></pre>
<p>The index is a routing table. The agent reads it, decides what it needs, loads only that.</p>
<h3>state.md</h3>
<p>One file, always overwritten. Never appended.</p>
<pre><code class="language-markdown"># Current State

Updated: 2026-04-11

## Active focus
Redesigning the memory system after observing degradation in production.
Building the hardened v2. Targeting minimal file count and no append logic.

## Recent context
- Completed: initial audit of degraded memory files
- Completed: team discussion and principles alignment
- In progress: implementing new structure across active projects

## Known blockers
None currently.
</code></pre>
<p>This is a snapshot, not a timeline. Next session, the agent rewrites it. Old state disappears. That is by design.</p>
<h3>decisions.md</h3>
<p>Decisions are updated in place. When a decision changes, the entry is rewritten — not a new entry added below.</p>
<pre><code class="language-markdown"># Decisions

## Styling approach
**Decision:** Tailwind CSS utility classes only.
**Reasoning:** Utility-first is fast for a small team, eliminates CSS naming arguments.
**Updated:** 2026-03-10

## Blog post storage
**Decision:** TypeScript files with markdown in template literals.
**Reasoning:** Type-safe content, no CMS needed, works with SSG.
**Updated:** 2026-03-15
</code></pre>
<p>Note the <code>Updated</code> field on every entry. This is the anti-drift mechanism. When you see a date from six months ago, you know to verify it is still current. When you update a decision, you update the date. Stale dates are a signal to review.</p>
<h3>facts.md</h3>
<p>Stable technical facts that rarely change. API shapes, schema, deployment configuration, environment behavior. Written once, updated when things change.</p>
<pre><code class="language-markdown"># Facts

## Authentication
- JWT tokens, 15-minute expiry
- Refresh tokens stored in httpOnly cookies
- Endpoint: POST /api/auth/refresh

## Deployment
- Static export via GitHub Actions
- Deployed to GitHub Pages
- Custom domain: ma-x.im
- Build command: next build (output: export)

## Data schema
- Blog posts: TypeScript files in src/data/posts/
- Each post exports { post: BlogPost }
- Registered in src/data/posts/index.ts
</code></pre>
<h3>tasks.md</h3>
<p>Active tasks only. Completed tasks are deleted, not archived.</p>
<pre><code class="language-markdown"># Active Tasks

## In progress
- Implement hardened memory structure across ma-x.im project

## Blocked
(none)

## Next
- Write documentation for the new structure
- Review and prune decisions.md across older projects

---
No completed tasks in this file. Completed = deleted.
</code></pre>
<p>This is a working board, not a history. Once a task is done, it disappears.</p>
<h2>Update Rules: When to Write and When to Stay Silent</h2>
<p>This was the most important thing we formalized. The first system had vague rules: &quot;record things when appropriate.&quot; Agents interpreted this as &quot;record everything always.&quot; The new rules are explicit about silence:</p>
<p><strong>Write when:</strong></p>
<ul>
<li>A decision was made that did not exist before</li>
<li>An existing decision was changed</li>
<li>A new stable fact was discovered</li>
<li>The current focus shifted significantly</li>
<li>A bug was encountered that reveals a non-obvious constraint</li>
</ul>
<p><strong>Do NOT write when:</strong></p>
<ul>
<li>You completed a routine task (the task file handles this, no memoir needed)</li>
<li>You repeated something already in memory</li>
<li>The session produced no new knowledge</li>
<li>You would only be adding a timestamp without changing content</li>
</ul>
<p><strong>Overwrite rules:</strong></p>
<ul>
<li><code>state.md</code> — always overwrite entirely</li>
<li><code>tasks.md</code> — remove completed tasks, add new ones</li>
<li><code>decisions.md</code> — edit the existing entry, update the date</li>
<li><code>facts.md</code> — edit in place, same as decisions</li>
</ul>
<p>If you cannot identify which existing entry to update — stop and check if the new information belongs in memory at all.</p>
<h2>Consolidation: What We Called &quot;Dreaming&quot;</h2>
<p>One thing we kept from the meeting discussion was the idea of an explicit consolidation step. Call it memory maintenance, pruning, or — more evocatively — dreaming. The agent is not doing real work during this step. It is reviewing its own memory for quality.</p>
<p>The consolidation prompt looks like this:</p>
<pre><code class="language-markdown">Review all memory files. For each file:
1. Remove entries that are no longer current or relevant
2. Merge entries that describe the same thing
3. Resolve contradictions — keep the most recent or most accurate entry
4. Compress verbose entries into concise ones
5. Check that facts.md and decisions.md have Updated dates
6. Verify tasks.md contains only active tasks

Do not add new information during consolidation.
Output a summary of what was changed and why.
</code></pre>
<p>Run this manually every two to four weeks, or when memory files feel like they are growing again. It takes a few minutes and resets the quality level. Think of it as code review, but for your agent&#39;s knowledge.</p>
<h2>Loading Strategy: Index First, Lazy Fetch</h2>
<p>The old system: agent reads everything, every session. Expensive and noisy.</p>
<p>The new system: agent reads <code>index.md</code> first, then decides what to load.</p>
<p>Pseudo-logic the agent follows:</p>
<pre><code>session_start:
  load index.md
  load state.md  (always — it is small and always relevant)

  if task involves technology decisions:
    load decisions.md
  
  if task involves API, schema, or deployment:
    load facts.md
  
  if task involves planning or sprint work:
    load tasks.md

session_end:
  if state has changed → overwrite state.md
  if a decision was made or changed → update decisions.md
  if new stable fact was discovered → update facts.md
  if tasks changed → update tasks.md
  if nothing materially changed → write nothing
</code></pre>
<p>The index also contains a topic-to-file routing table. If the task is about authentication, the agent looks up &quot;auth&quot; in the index, sees it maps to <code>facts.md#authentication</code>, and loads only that section if the file supports section loading.</p>
<h2>Good Memory vs Bad Memory</h2>
<p>Here is what the degraded system looked like, and what the hardened version looks like for the same information.</p>
<p><strong>Bad — lessons.md after three weeks:</strong></p>
<pre><code class="language-markdown">- Fixed a bug with template literals and backticks - need to escape them
- Tailwind is our CSS approach
- Remember to update the blog index when adding posts
- Spent 2 hours debugging the sitemap — turned out to be a missing trailing slash
- The agent keeps using CSS modules even though we use Tailwind — told it again
- Fixed template literal backtick issue again in a different file
- Reminder: blog posts need to be registered in index.ts
- Auth tokens expire after 15 min — need refresh logic
- AGAIN: escape backticks in template literals
</code></pre>
<p>Three separate entries about the same backtick bug. Two reminders about the blog index. This is what append-only looks like in practice.</p>
<p><strong>Good — facts.md with a single consolidated entry:</strong></p>
<pre><code class="language-markdown">## Blog post authoring
- Posts are TypeScript files in src/data/posts/, registered in index.ts
- Content is a template literal — backticks inside content must be escaped as \`
- Dates use format: &quot;Apr 11, 2026&quot;
</code></pre>
<p>One entry. Everything relevant. Zero repetition.</p>
<h2>Honest Trade-offs</h2>
<p>This system is better, but it is not free:</p>
<p><strong>You lose history.</strong> There is no record of what was decided and then changed. If you care about that trail, decisions.md has <code>Updated</code> dates, but it does not track the previous value. For audit purposes, commit your memory files to version control — git gives you the history.</p>
<p><strong>Consolidation requires effort.</strong> The dreaming step will not happen automatically unless you explicitly schedule it. Without it, even this system will degrade eventually — just much more slowly.</p>
<p><strong>The agent still needs good instructions.</strong> Vague instructions produce vague memory behavior. The more specific your rules (especially the &quot;do NOT write&quot; cases), the more disciplined the output.</p>
<p><strong>Index routing is manual.</strong> The topic-to-file mappings in <code>index.md</code> need to be maintained by hand. A stale index routes the agent to the wrong file. Keep it accurate.</p>
<h2>Summary</h2>
<p>I submitted that PR thinking I was sharing something solid. The pre-meeting chat humbled me a little — people had hit the same walls I had not hit yet, they just had not written about it publicly.</p>
<p>The honest conclusion from all of that experience: the naive version works until it does not, and when it stops working it does so quietly. You do not get an error. You get an agent that does almost the right thing, confidently, based on stale context you forgot was there. That is harder to debug than a crash.</p>
<p>The hardened version applies one core idea: <strong>memory should represent current knowledge, not accumulated history.</strong> Everything else follows from that — overwrite instead of append, index-based loading instead of bulk dumps, explicit silence rules, no sessions folder.</p>
<p>The agent does not need a journal. It needs a working knowledge base that stays reliable over time.</p>
<p>Build that instead.</p>
]]></content:encoded>
      <pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate>
      <category>AI</category>
      <category>Architecture</category>
      <category>DX</category>
      <category>Workflow</category>
    </item>
    <item>
      <title>Charts in React: Who Renders the Pixels</title>
      <link>https://ma-x.im/blog/react-playbook-chart-libraries</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-chart-libraries</guid>
      <description>&quot;Just use a chart library&quot; hides a real decision. Do you want charts handed to you, or do you want to control every pixel? That choice — and your data size — decides between Recharts, visx, and raw D3. Here&apos;s how I pick, and where charts actually get hard.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-chart-libraries/cover.png" alt="Charts in React: Who Renders the Pixels" /></p>
<p>The chart looked perfect in the demo. Clean line, smooth animation, a tooltip that felt expensive. Then real data showed up — forty thousand points instead of the twenty I&#39;d tested with — and the tab froze solid every time you hovered.</p>
<p>That&#39;s the moment you learn charts aren&#39;t a UI problem. They&#39;re a rendering problem wearing a UI costume.</p>
<p>&quot;Just use a chart library&quot; is the advice everyone gives, and it&#39;s not wrong, exactly. It&#39;s just hiding the actual decision. There are a dozen chart libraries and they are not interchangeable. Picking one commits you to a rendering model, a customization ceiling, and a performance profile — and you usually discover all three at the worst possible time, which is after the design changes or the data grows.</p>
<p>So this article is about how I actually choose, and the question underneath the choice is the same one that decided the <a href="https://ma-x.im/blog/react-playbook-ui-libraries">UI library article</a> and the <a href="https://ma-x.im/blog/react-playbook-form-libraries">forms article</a>: who owns the thing being rendered — the library, or me?</p>
<h2>The Two Questions That Decide Everything</h2>
<p>Before any library names, two questions do most of the sorting.</p>
<p><strong>How much do you need to control the visuals?</strong> A dashboard with standard bar and line charts is a different problem from a bespoke visualization no chart library has a preset for. The first wants defaults. The second wants primitives.</p>
<p><strong>How big is your data, and how interactive is it?</strong> A hundred points rendered as SVG is nothing. A hundred thousand points, hovering and zooming, is where SVG dies and you need Canvas or WebGL. This one question quietly eliminates half the options before you&#39;ve read their docs.</p>
<p>Everything below is really just these two axes: control vs done-for-you, and data size vs interactivity. Hold them in your head and the landscape stops looking like a dozen equivalent choices.</p>
<h2>Recharts: The Sensible Default</h2>
<p>For the majority of what people actually build — dashboards, admin panels, the CRM-shaped screens this series keeps returning to — Recharts is where I start. It&#39;s React-native and composable: charts are components, and you build them the way you build the rest of your UI.</p>
<pre><code class="language-tsx">import { LineChart, Line, XAxis, YAxis, Tooltip } from &#39;recharts&#39;;

function RevenueChart({ data }: { data: MonthlyRevenue[] }) {
  return (
    &lt;LineChart width={640} height={320} data={data}&gt;
      &lt;XAxis dataKey=&quot;month&quot; /&gt;
      &lt;YAxis /&gt;
      &lt;Tooltip /&gt;
      &lt;Line type=&quot;monotone&quot; dataKey=&quot;revenue&quot; stroke=&quot;#22c55e&quot; /&gt;
    &lt;/LineChart&gt;
  );
}
</code></pre>
<p>Look at what that is: no imperative setup, no lifecycle wiring, no D3 selections. Axes are components. The line is a component. You compose a chart the way you compose a form or a page, and it fits a React codebase without friction. For standard chart types it&#39;s readable, quick, and easy to hand to another developer.</p>
<p>The ceiling is real, though, and worth naming. Recharts is great until your design asks for something outside its component vocabulary — a custom axis behavior, an unusual interaction, a shape it doesn&#39;t ship. Then you&#39;re either fighting its internals or reaching for something lower-level. For 80% of dashboards that ceiling is far above what you need. For the other 20%, you feel it early.</p>
<p>There are close siblings here — Nivo, Victory, react-chartjs-2 — and I won&#39;t pretend to rank them precisely. Nivo&#39;s defaults are beautiful and it has a Canvas escape hatch for larger datasets. Chart.js (via react-chartjs-2) renders to Canvas out of the box, which helps with volume but makes it feel less React-native. They cluster in the same place: high-level, done-for-you, great until the design goes off-script.</p>
<h2>visx: Primitives, Not Charts</h2>
<p>When the design <em>does</em> go off-script — when you&#39;re building a visualization rather than picking a chart — visx is what I reach for.</p>
<p>visx (from Airbnb) is a set of low-level primitives built on D3&#39;s math, wrapped so React owns the rendering. D3 does the scales, the shapes, the layout calculations; React draws the actual SVG. You don&#39;t get a <code>&lt;BarChart&gt;</code>. You get scales, axes, shapes, gradients — the pieces — and you assemble the exact chart you want.</p>
<pre><code class="language-tsx">import { scaleLinear } from &#39;@visx/scale&#39;;
import { Bar } from &#39;@visx/shape&#39;;

const xScale = scaleLinear({ domain: [0, maxValue], range: [0, width] });

function CustomBars({ data }: { data: Point[] }) {
  return data.map((d) =&gt; (
    &lt;Bar key={d.id} x={0} y={yScale(d.label)} width={xScale(d.value)} height={18} /&gt;
  ));
}
</code></pre>
<p>This is the same move as Radix and shadcn/ui from the earlier articles: the library gives you behavior and math, and hands the pixels back to you. The cost is honest — you write more, you think about scales and margins yourself, and there&#39;s no &quot;just drop in a chart.&quot; The payoff is that there&#39;s no ceiling. If you can describe it, you can draw it, and React stays in control of every element.</p>
<p>I don&#39;t reach for visx for a revenue line chart. That would be building a table saw to cut one board. I reach for it when the visualization <em>is</em> the product feature, not a readout beside it.</p>
<h2>Raw D3: Powerful, and at Odds with React</h2>
<p>D3 is the foundation under most of this, and you can absolutely use it directly. I mostly don&#39;t, and the reason is architectural, not a knock on D3.</p>
<p>D3 wants to own the DOM. Its whole model is selecting elements and mutating them imperatively — enter, update, exit. React also wants to own the DOM, through its virtual model. Point them at the same nodes and you get two systems fighting over who&#39;s in charge, which shows up as subtle bugs where React re-renders away what D3 just drew, or vice versa.</p>
<p>The clean pattern is to split the roles: <strong>let D3 do the math, let React do the rendering.</strong> Use D3 for scales, layouts, and path generators — pure functions that take data and return numbers or SVG path strings — and let React turn those into elements. That&#39;s exactly what visx formalizes, which is why I&#39;d rather use visx than wire that boundary by hand. If you do reach for raw D3 inside React, keep it to the calculation layer and let React render. The moment D3 starts calling <code>.append()</code> on nodes React manages, you&#39;ve bought yourself a class of bugs that&#39;s genuinely hard to reason about.</p>
<h2>The Thing Nobody Warns You About: SVG vs Canvas</h2>
<p>Here&#39;s the constraint that ambushed me in the opening story, and it&#39;s independent of which library you pick.</p>
<p>Most chart libraries render to SVG, and SVG is wonderful — every point is a DOM node you can style, inspect, and attach handlers to. Until there are too many of them. Ten thousand DOM nodes is a slow page. Fifty thousand is a frozen one. The browser simply wasn&#39;t built to keep that many elements interactive.</p>
<p>Past that threshold you need <strong>Canvas</strong> (one element, drawn imperatively, no per-point DOM) or <strong>WebGL</strong> for the truly massive. This changes what you&#39;re allowed to pick. If you know up front you&#39;re plotting a hundred thousand time-series points with hover and zoom, that fact chooses your library before any aesthetic preference does — you filter to the ones with a Canvas renderer and decide from there. Choosing a beautiful SVG library and <em>then</em> discovering the data size is how you end up rewriting the chart layer under deadline.</p>
<p>So I&#39;ve learned to ask the data-size question first, not last. It&#39;s the least glamorous input and the most decisive one.</p>
<h2>How I Actually Decide</h2>
<p>Strip it down and it&#39;s short:</p>
<ul>
<li><strong>Standard dashboard charts, reasonable data size</strong> → Recharts. React-native, composable, done. This is most cases.</li>
<li><strong>Beautiful defaults, occasionally large data</strong> → Nivo, for its presets and Canvas option.</li>
<li><strong>A custom visualization that&#39;s a real product feature</strong> → visx. Own the pixels, no ceiling.</li>
<li><strong>Genuinely huge datasets, heavy interaction</strong> → a Canvas/WebGL renderer, and let data size lead the choice.</li>
<li><strong>Raw D3</strong> → for the math, behind a React rendering layer. Rarely for the DOM directly.</li>
</ul>
<p>For the Playbook, Recharts is the default and visx is the escape hatch — the same shape as every other choice in this series. Start with the composable, React-native option that agrees with the rest of the stack; drop to primitives only when the design demands it.</p>
<p>But I want to end where the real difficulty lives, because it isn&#39;t the library. The hard part of a chart is never the rendering — it&#39;s the data. Getting your series into the exact shape the chart wants, aggregating and bucketing correctly, choosing the visualization that answers the actual question instead of the one that looks impressive. A library renders whatever you hand it. It has no opinion on whether the chart is honest, or clear, or answering the right question. That part is yours, and no dependency will do it for you.</p>
<p>Data going in, data coming out — we&#39;ve covered both. The next article turns to something every real app needs and everyone underestimates: <a href="https://ma-x.im/blog/react-playbook-authentication">authentication</a>. Rolling your own versus buying it, and where that decision quietly reshapes your whole architecture.</p>
<p>If you&#39;ve hit the SVG performance wall, or fought D3 and React over the same DOM, I&#39;d like to hear how it went — those are the war stories where the interesting trade-offs actually surface, and I&#39;m always collecting the ones I haven&#39;t run into yet.</p>
]]></content:encoded>
      <pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Data Visualization</category>
      <category>Charts</category>
      <category>Recharts</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Forms in React: Where TanStack Form Earns Its Place</title>
      <link>https://ma-x.im/blog/react-playbook-form-libraries</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-form-libraries</guid>
      <description>Forms are where clean React apps quietly fall apart. Conditional fields, async validation, a submit button that has to know about everything — it adds up fast. Here&apos;s why I reach for TanStack Form, where it beats Formik and React Hook Form, and where a form library still can&apos;t save you.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-form-libraries/cover.png" alt="Forms in React: Where TanStack Form Earns Its Place" /></p>
<p>Every codebase has one file everyone is a little afraid of. In the apps I&#39;ve worked on, it&#39;s usually a form.</p>
<p>Not a login form. Those are fine. I mean the real one — the checkout, the policy application, the onboarding flow with the field that only appears when you pick &quot;business account,&quot; which itself unlocks a tax ID field, which has to be validated against a server, but only after the user stops typing, and only if the country is one of four. That form. The one where every new requirement lands as another <code>if</code> somewhere in a 600-line component.</p>
<p>Forms are where clean React quietly falls apart. Everywhere else you can lean on good structure and it holds. Forms fight back. They mix local state, async work, cross-field logic, and user timing into one knot, and the naive version — a pile of <code>useState</code> and a submit handler that reads all of them — works right up until the requirements get real.</p>
<p>So this article is about the tool I reach for when a form stops being trivial, and, just as importantly, about the moment you actually need one. Because reaching for a form library too early is its own mistake.</p>
<h2>When You Don&#39;t Need a Form Library</h2>
<p>Let me start against my own topic. A lot of forms don&#39;t need a library at all.</p>
<pre><code class="language-tsx">function NewsletterSignup() {
  const [email, setEmail] = useState(&#39;&#39;);

  return (
    &lt;form onSubmit={handleSubmit}&gt;
      &lt;input value={email} onChange={(e) =&gt; setEmail(e.target.value)} /&gt;
      &lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;
    &lt;/form&gt;
  );
}
</code></pre>
<p>That&#39;s a form. It&#39;s fine. One field, no cross-field rules, validation you can do in a single line. Adding a library here buys you nothing but an import. I&#39;ve seen people install a form framework for a search box, and it always reads like insecurity — reaching for structure the problem doesn&#39;t have yet.</p>
<p>The line I use is simple: <strong>the moment fields start depending on each other, or validation has to talk to a server, or the same form has more than a handful of inputs, the hand-rolled version starts costing more than it saves.</strong> Below that line, <code>useState</code> is the right answer. Above it, you want a real tool. Most interesting forms sit above it.</p>
<h2>What Actually Makes Forms Hard</h2>
<p>Before the library, it&#39;s worth naming what we&#39;re fighting, because the pain isn&#39;t &quot;typing out inputs.&quot; It&#39;s four things that stack:</p>
<ul>
<li><strong>State that spreads.</strong> Every field is state, but so is <em>touched</em>, <em>dirty</em>, <em>validating</em>, <em>error</em>, <em>submitting</em>. Multiply by twenty fields.</li>
<li><strong>Cross-field logic.</strong> Field B is required only if field A has a value. Field C&#39;s options depend on field D. The rules form a little graph.</li>
<li><strong>Async validation.</strong> &quot;Is this username taken?&quot; means a server round-trip, debounced, cancelable, with a pending state the UI has to show.</li>
<li><strong>Re-render cost.</strong> The naive approach lifts everything to one state object, so one keystroke re-renders the entire form. On a big form you feel it.</li>
</ul>
<p>A good form library is really a tool for managing that specific mess. That&#39;s the lens I judge them through — not &quot;does it render inputs,&quot; but &quot;does it make those four things smaller.&quot;</p>
<h2>Why TanStack Form</h2>
<p>There are three names worth taking seriously: Formik, React Hook Form, and TanStack Form. I&#39;ll be fair to all of them, because none is a bad tool.</p>
<p><strong>Formik</strong> was the default for years. It&#39;s stable and widely known. But its model re-renders broadly by default, and its TypeScript story shows its age — types feel bolted on rather than designed in. On a large, dynamic form you end up working around it.</p>
<p><strong>React Hook Form</strong> is genuinely good, and if you&#39;re happy with it I won&#39;t try to talk you out of it. It&#39;s fast, because it leans on uncontrolled inputs and refs, and it&#39;s the right answer for a lot of teams. My hesitation is narrow: that ref-based, uncontrolled model gets awkward exactly where my forms get hard — deeply dynamic fields, tightly controlled components, cross-field reactivity. It optimizes for the common case in a way that can fight the uncommon one.</p>
<p><strong>TanStack Form</strong> is the one I default to in this series, and the reason is consistency more than superiority. It&#39;s type-safe to the core — your field names, values, and errors are all inferred, so renaming a field is a compiler problem, not a runtime surprise. It&#39;s granularly reactive: you subscribe to the exact slice of form state a component cares about, and only that component re-renders. And it&#39;s headless and framework-agnostic, which means it makes no styling decisions and hands control back to you — the same principle that decided the <a href="https://ma-x.im/blog/react-playbook-ui-libraries">UI library article</a>.</p>
<p>If you&#39;ve followed this series, that reactivity model should feel familiar. It&#39;s the same instinct behind the <a href="https://ma-x.im/blog/react-playbook-reatom-state-management">Reatom choice</a>: fine-grained subscriptions instead of one big blob that re-renders on every change. A form is just reactive state with a lot of edges, and TanStack Form treats it that way.</p>
<h2>The Shape of It</h2>
<p>Here&#39;s the same policy form, the kind this series keeps circling, wired with TanStack Form.</p>
<pre><code class="language-tsx">import { useForm } from &#39;@tanstack/react-form&#39;;

function PolicyApplication() {
  const form = useForm({
    defaultValues: {
      accountType: &#39;personal&#39;,
      holderName: &#39;&#39;,
      taxId: &#39;&#39;,
    },
    onSubmit: async ({ value }) =&gt; {
      await submitApplication(value);
    },
  });

  return (
    &lt;form
      onSubmit={(e) =&gt; {
        e.preventDefault();
        form.handleSubmit();
      }}
    &gt;
      &lt;form.Field name=&quot;holderName&quot;&gt;
        {(field) =&gt; (
          &lt;input
            value={field.state.value}
            onBlur={field.handleBlur}
            onChange={(e) =&gt; field.handleChange(e.target.value)}
          /&gt;
        )}
      &lt;/form.Field&gt;
    &lt;/form&gt;
  );
}
</code></pre>
<p>The verbosity is real, and I won&#39;t pretend otherwise — the render-prop <code>Field</code> component is more ceremony than React Hook Form&#39;s <code>register</code>. But look at what you get for it: <code>field.state.value</code> is typed from <code>defaultValues</code>, <code>name=&quot;holderName&quot;</code> is checked against the same shape, and everything inside that <code>Field</code> re-renders in isolation. The ceremony is buying type safety and surgical re-renders. On a small form that&#39;s a bad trade. On the 600-line one, it&#39;s the whole point.</p>
<h2>Conditional Fields Without the Spaghetti</h2>
<p>This is the part that sold me. Remember the &quot;business account unlocks a tax ID field&quot; requirement — the one that becomes an <code>if</code> in a naive form. Here it&#39;s just reading one field&#39;s value to decide whether to render another.</p>
<pre><code class="language-tsx">&lt;form.Subscribe selector={(state) =&gt; state.values.accountType}&gt;
  {(accountType) =&gt;
    accountType === &#39;business&#39; ? (
      &lt;form.Field
        name=&quot;taxId&quot;
        validators={{
          onChange: ({ value }) =&gt;
            value.length === 0 ? &#39;Tax ID is required for business accounts&#39; : undefined,
        }}
      &gt;
        {(field) =&gt; (
          &lt;input
            value={field.state.value}
            onChange={(e) =&gt; field.handleChange(e.target.value)}
          /&gt;
        )}
      &lt;/form.Field&gt;
    ) : null
  }
&lt;/form.Subscribe&gt;
</code></pre>
<p>Two things matter here. First, <code>form.Subscribe</code> with a <code>selector</code> means only <em>this</em> branch re-renders when <code>accountType</code> changes — the rest of the form doesn&#39;t flinch. Second, the validation rule lives <em>on the field that owns it</em>, not in a giant central schema that has to know about every conditional. The tax-ID rule exists only when the tax-ID field exists. When the field unmounts, its rule goes with it. That co-location is what keeps dynamic forms from rotting.</p>
<h2>Async Validation That Doesn&#39;t Fight You</h2>
<p>The &quot;is this taken?&quot; problem is where hand-rolled forms accumulate the most incidental complexity — debouncing, cancellation, a pending flag, a race condition you didn&#39;t see coming. TanStack Form has a slot for exactly this.</p>
<pre><code class="language-tsx">&lt;form.Field
  name=&quot;username&quot;
  validators={{
    onChangeAsyncDebounceMs: 400,
    onChangeAsync: async ({ value }) =&gt; {
      const taken = await checkUsernameTaken(value);
      return taken ? &#39;That username is already in use&#39; : undefined;
    },
  }}
&gt;
  {(field) =&gt; (
    &lt;&gt;
      &lt;input
        value={field.state.value}
        onChange={(e) =&gt; field.handleChange(e.target.value)}
      /&gt;
      {field.state.meta.isValidating &amp;&amp; &lt;span&gt;Checking…&lt;/span&gt;}
      {field.state.meta.errors[0] &amp;&amp; &lt;span&gt;{field.state.meta.errors[0]}&lt;/span&gt;}
    &lt;/&gt;
  )}
&lt;/form.Field&gt;
</code></pre>
<p>The library owns the parts that are easy to get subtly wrong: the debounce, the in-flight tracking via <code>isValidating</code>, cancelling a stale check when the value changes again. You own the one thing that&#39;s actually your business logic — the <code>checkUsernameTaken</code> call. That&#39;s the right division of labor. I don&#39;t want to write another debounced-async-with-cancellation dance by hand; I&#39;ve written it enough times to know I&#39;ll get the edge case wrong.</p>
<h2>Let the Schema Do the Talking</h2>
<p>Inline validators are fine for one-off rules, but real forms usually already have a schema — and TanStack Form is happy to defer to it. Point a field&#39;s validator at a Zod schema and the types and the rules come from one source.</p>
<pre><code class="language-tsx">import { z } from &#39;zod&#39;;

const holderName = z.string().min(2, &#39;Name is too short&#39;);

&lt;form.Field name=&quot;holderName&quot; validators={{ onChange: holderName }}&gt;
  {(field) =&gt; (/* … */)}
&lt;/form.Field&gt;
</code></pre>
<p>Now the validation rule and the TypeScript type live in the same place, and neither can drift from the other. This is the same instinct as everything else in the series: one source of truth, let the compiler enforce it. A form is often where a Zod schema you already have — for your API payload — gets a second job validating the UI. Reuse it. Don&#39;t write the shape twice.</p>
<h2>Where a Form Library Still Can&#39;t Save You</h2>
<p>I want to end honestly, because it&#39;s easy to oversell this. A form library manages state, validation, and re-renders. It does not manage <em>product complexity</em>, and most painful forms are painful because the product is.</p>
<p>Multi-step wizards with a back button that has to preserve state. Fields whose valid options depend on an API call that depends on another field. Save-as-draft that has to serialize a half-finished form. A submit that&#39;s really five API calls with partial-failure handling. TanStack Form gives you a clean foundation for all of it — but the wizard&#39;s flow, the dependency graph, the draft format, the failure strategy are yours to design. The library keeps the state honest. It doesn&#39;t tell you what the state should be.</p>
<p>That&#39;s the realistic version. The right tool removes the incidental pain — the re-renders, the type drift, the debounce dance — so that what&#39;s left is the <em>essential</em> pain, the actual product logic. That&#39;s the most any library can do, and it&#39;s a lot. A form that only hurts where the problem is genuinely hard is a form you can live with.</p>
<p>For the Playbook, TanStack Form is the choice, for the same reason shadcn/ui and Reatom were: it&#39;s headless, type-safe, and granularly reactive, so it points the same direction as everything else we&#39;ve built. Coherence over any single feature.</p>
<p>Forms handle the data going <em>in</em>. The next article is about making sense of data coming <em>out</em> — <a href="https://ma-x.im/blog/react-playbook-chart-libraries">visualization and charting in React</a>, and why &quot;just use a chart library&quot; is a much bigger decision than it sounds.</p>
<p>If you&#39;ve got a form in your codebase that everyone tiptoes around, I&#39;d genuinely like to hear what makes it hard — the specific requirement that broke the clean version. That&#39;s usually where the interesting design problems live, and I&#39;m always looking for the ones I haven&#39;t hit yet.</p>
]]></content:encoded>
      <pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Forms</category>
      <category>TanStack Form</category>
      <category>Validation</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>React UI Libraries: Who Owns the Styling Decides Everything</title>
      <link>https://ma-x.im/blog/react-playbook-ui-libraries</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-ui-libraries</guid>
      <description>Picking a component library feels like picking buttons and modals. It isn&apos;t. You&apos;re deciding who controls your styling — you or the library — and that single choice shapes how much you&apos;ll fight it for years. Here&apos;s how I weigh MUI, Ant Design, Radix, and shadcn/ui.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-ui-libraries/cover.png" alt="React UI Libraries: Who Owns the Styling Decides Everything" /></p>
<p>The demo always goes well. That&#39;s the trap.</p>
<p>You&#39;re evaluating a component library. You drop in their date picker, their data table, their modal. In twenty minutes you have something that looks polished, and the decision feels made. Ship it.</p>
<p>The problems don&#39;t show up in the demo. They show up eight months later, when a designer hands you a spec that doesn&#39;t match the library&#39;s defaults, and you discover that changing one border radius means fighting three layers of the library&#39;s own styling system. That&#39;s when you learn what you actually signed up for.</p>
<p>So I&#39;ve stopped evaluating UI libraries by how good the demo looks. The question I ask now is different, and it&#39;s uncomfortable:</p>
<blockquote>
<p>When my design diverges from the library&#39;s defaults — and it will — who wins, me or the library?</p>
</blockquote>
<p>That question splits the entire landscape cleanly. And it connects straight back to the <a href="https://ma-x.im/blog/react-playbook-css-styling">styling decision from the previous article</a>: once you&#39;ve committed to Tailwind and a token system, a component library that brings its <em>own</em> styling engine isn&#39;t a neat addition. It&#39;s a second opinion arguing with your first one.</p>
<p>Let me walk through the four I actually consider — MUI, Ant Design, Radix, and shadcn/ui — grouped not by popularity, but by who holds the pen.</p>
<h2>Two Families, Not Four Libraries</h2>
<p>Before the individual names, the split that matters:</p>
<ul>
<li><strong>Batteries-included, styled libraries</strong> — MUI, Ant Design. They give you finished, good-looking components <em>and</em> the styling system that drives them. You adopt their look and their theming model.</li>
<li><strong>Headless / you-own-the-styles</strong> — Radix, shadcn/ui. They give you behavior and accessibility, and hand the styling back to you.</li>
</ul>
<p>Almost everything else follows from which family you pick. The first family gets you to &quot;looks done&quot; fastest. The second gets you to &quot;looks like <em>ours</em>&quot; without a fight. Neither is wrong. They&#39;re answers to different questions.</p>
<h2>MUI: Powerful, Complete, and Opinionated</h2>
<p>MUI (Material UI) is the library I&#39;d hand someone who needs a lot of surface area, fast, and doesn&#39;t have a strong custom design language to protect.</p>
<pre><code class="language-tsx">import { Button, TextField } from &#39;@mui/material&#39;;

function PolicyForm() {
  return (
    &lt;form&gt;
      &lt;TextField label=&quot;Holder name&quot; variant=&quot;outlined&quot; /&gt;
      &lt;Button variant=&quot;contained&quot;&gt;Save&lt;/Button&gt;
    &lt;/form&gt;
  );
}
</code></pre>
<p>What&#39;s genuinely great: the component coverage is enormous. Data grids, date pickers, autocomplete, everything. The accessibility is solid. For an internal tool or a dashboard where &quot;clean and consistent&quot; beats &quot;uniquely ours,&quot; MUI gets you there remarkably fast.</p>
<p>Here&#39;s my reservation, and it&#39;s the same one that decides most of this article. MUI brings its own styling engine — historically Emotion, a runtime CSS-in-JS system. You saw in the last article why I&#39;ve cooled on runtime CSS-in-JS: the cost moved, and it sits awkwardly with Server Components. MUI inherits that tension. And it carries a point of view — Material Design — that you either want, or spend a long time theming your way out of. Overriding MUI deeply means learning MUI&#39;s override system, which is its own body of knowledge.</p>
<p>MUI isn&#39;t a bad choice. It&#39;s a <em>committing</em> choice. You&#39;re adopting a design language and a styling runtime along with the components.</p>
<h2>Ant Design: Enterprise Density Out of the Box</h2>
<p>Ant Design is MUI&#39;s closest sibling in philosophy, aimed at a slightly different target: dense, data-heavy enterprise applications.</p>
<p>If you&#39;re building an admin panel with complex tables, filters, and forms — the CRM-shaped software this series keeps circling back to — Ant&#39;s defaults are tuned for exactly that. The tables alone save weeks.</p>
<p>The trade-off is the same shape as MUI&#39;s, just with a different accent. You&#39;re adopting Ant&#39;s visual language, which is strong and distinctly <em>Ant</em> — apps built with it tend to look like they&#39;re built with it. Deep customization means working within Ant&#39;s theming system, and stepping outside its defaults gets progressively harder the further you go. Honestly, if your product needs to look like your brand rather than like an Ant demo, this is the friction you&#39;ll feel most.</p>
<p>Both MUI and Ant answer the question &quot;how do I get a lot of good components quickly?&quot; Well. The next two answer a different question: &quot;how do I keep control of how everything looks?&quot;</p>
<h2>Radix: Behavior Without the Costume</h2>
<p>Radix Primitives took the part that&#39;s genuinely hard — accessible, correct interactive behavior — and shipped <em>only</em> that. No visual design at all.</p>
<pre><code class="language-tsx">import * as Dialog from &#39;@radix-ui/react-dialog&#39;;

function ConfirmDialog() {
  return (
    &lt;Dialog.Root&gt;
      &lt;Dialog.Trigger&gt;Delete policy&lt;/Dialog.Trigger&gt;
      &lt;Dialog.Portal&gt;
        &lt;Dialog.Overlay className=&quot;fixed inset-0 bg-black/40&quot; /&gt;
        &lt;Dialog.Content className=&quot;fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6&quot;&gt;
          &lt;Dialog.Title className=&quot;text-sm font-semibold&quot;&gt;Are you sure?&lt;/Dialog.Title&gt;
        &lt;/Dialog.Content&gt;
      &lt;/Dialog.Portal&gt;
    &lt;/Dialog.Root&gt;
  );
}
</code></pre>
<p>Look at what Radix gives you and what it doesn&#39;t. It gives you the dialog&#39;s focus trapping, escape-to-close, scroll locking, ARIA wiring, portal handling — all the things that are tedious and easy to get subtly wrong. It gives you <em>no</em> appearance. Every <code>className</code> there is mine, and it&#39;s Tailwind, and Radix doesn&#39;t care.</p>
<p>That&#39;s the whole point. Radix is what you reach for when accessibility matters and you refuse to inherit someone else&#39;s look. The cost is honest: you build the visual layer yourself. For a one-off that&#39;s work. Which is exactly the gap the last option fills.</p>
<h2>shadcn/ui: The One That Fits This Series</h2>
<p>shadcn/ui isn&#39;t really a library, and that&#39;s the clever part. It&#39;s a collection of components built <em>on</em> Radix, styled <em>with</em> Tailwind, that you copy into your own codebase.</p>
<p>You don&#39;t <code>npm install</code> a black box. You run a command, and the component&#39;s source lands in your project as your code:</p>
<pre><code class="language-bash">npx shadcn@latest add dialog
</code></pre>
<p>Now <code>components/ui/dialog.tsx</code> is <em>yours</em>. Radix underneath for behavior, Tailwind classes for the look, and every line editable because it lives in your repo. There&#39;s no override system to learn — if you want a different border radius, you change the border radius, because it&#39;s just a file you own.</p>
<p>I&#39;ll be direct about why this fits the Playbook so well. We chose Tailwind and a token system in the last article. shadcn/ui is built on exactly that foundation — its components read from your Tailwind tokens, so they inherit your design system instead of imposing one. Radix gives the behavior I&#39;d otherwise have to build by hand. And because the code is mine, the library can never become the thing I&#39;m fighting. There&#39;s no black box to fight <em>with</em>.</p>
<p>The trade-off is real and worth stating: you own more code. Updates aren&#39;t an <code>npm update</code> — you re-pull components or merge changes by hand. For some teams that ownership is a burden. For a team that wants its product to look like itself and never wants to be trapped by a vendor&#39;s styling decisions, it&#39;s the feature, not the cost.</p>
<h2>The Decision, Honestly</h2>
<p>Strip away the popularity contests and it comes down to that one question — who owns the styling:</p>
<ul>
<li><strong>You need enormous component coverage fast, and a strong custom look isn&#39;t the priority</strong> → MUI. Adopt its language on purpose.</li>
<li><strong>You&#39;re building dense, data-heavy enterprise screens</strong> → Ant Design. Its defaults are tuned for exactly that.</li>
<li><strong>You need accessible behavior but insist on owning every pixel</strong> → Radix primitives, styled yourself.</li>
<li><strong>You want owned, Tailwind-native components with Radix behavior underneath</strong> → shadcn/ui. This is my default for the kind of app this series builds.</li>
</ul>
<p>Notice that the &quot;right&quot; answer flips entirely based on one thing: how much your product needs to look like <em>yours</em> versus how fast it needs to look <em>done</em>. There&#39;s no library that wins both, and anyone selling you one hasn&#39;t hit month eight yet.</p>
<p>For the Playbook, I&#39;ll build on shadcn/ui, because it&#39;s the choice that agrees with every decision we&#39;ve made so far instead of arguing with one of them. That coherence — styling, tokens, components all pointing the same way — matters more than any single component&#39;s polish.</p>
<p>Components give you the pieces. The next article is about the hardest piece to get right: forms. We&#39;ll build real ones with <a href="https://ma-x.im/blog/react-playbook-form-libraries">TanStack Form</a>, the kind with conditional fields, async validation, and the messy requirements that make forms the place UIs go to die.</p>
<p>If you&#39;re weighing a component library right now, do one thing before you commit: try to restyle its busiest component to look nothing like the default. However that fight goes is your real answer. And if you&#39;ve made peace with MUI or Ant at scale and found the customization ceiling higher than I&#39;m describing, tell me — I&#39;d genuinely like to know where the line actually sits.</p>
]]></content:encoded>
      <pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>UI Libraries</category>
      <category>shadcn/ui</category>
      <category>Radix</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Styling React in 2026: How I Actually Choose</title>
      <link>https://ma-x.im/blog/react-playbook-css-styling</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-css-styling</guid>
      <description>Styling is the most argued-about, least reasoned-about decision in a React app. The real question isn&apos;t which library looks nicest in a demo — it&apos;s which one survives a growing codebase. Here&apos;s how I weigh Tailwind, CSS Modules, CSS-in-JS, and zero-runtime options, and where each one earns its place.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-css-styling/cover.png" alt="Styling React in 2026: How I Actually Choose" /></p>
<p>Ask ten React developers how to style a component and you&#39;ll start a fight.</p>
<p>Not a discussion. A fight. Tailwind people and CSS-in-JS people don&#39;t disagree about syntax — they disagree about identity. Somewhere along the way, &quot;how do we apply styles&quot; turned into a values debate, and the actual engineering question got buried under it.</p>
<p>Here&#39;s the thing that bugs me about that debate: almost none of it is about the question that matters. It&#39;s about which approach looks cleanest in a ten-line demo. But nobody styles a ten-line demo for a living. You style an application that grows, gets handed to new people, adds a design system halfway through, and has to survive a framework upgrade.</p>
<p>So the question I actually ask isn&#39;t &quot;which one is prettiest.&quot; It&#39;s:</p>
<blockquote>
<p>When this codebase triples in size and I wasn&#39;t the one who wrote half of it, which styling approach still makes sense?</p>
</blockquote>
<p>That reframes everything. Let me walk through the real options the way I weigh them — Tailwind, CSS Modules, CSS-in-JS, and the zero-runtime newcomers — and where each one genuinely fits.</p>
<h2>First, The Question Under The Question</h2>
<p>Every styling approach is really answering three separate questions, and people conflate them:</p>
<ol>
<li><strong>Where does the style live?</strong> Same file as the component, a co-located file, or a global sheet.</li>
<li><strong>When is the style computed?</strong> At build time, or at runtime in the browser.</li>
<li><strong>How do you stay consistent?</strong> Ad-hoc values, or tokens and constraints.</li>
</ol>
<p>Most &quot;Tailwind vs styled-components&quot; arguments are really arguments about question 2 without anyone saying so. Runtime cost is the thing that quietly decides a lot of this in 2026, especially with React Server Components in the picture. Keep those three questions in mind — they&#39;ll do more for you than any hot take.</p>
<h2>Tailwind: The Default I Keep Coming Back To</h2>
<p>I&#39;ll be honest about my bias up front: Tailwind is my default. Not because it&#39;s fashionable, and not without reservations. Because of what it does to a <em>team</em>, not to a single component.</p>
<pre><code class="language-tsx">function PolicyCard({ policy }: { policy: Policy }) {
  return (
    &lt;article className=&quot;rounded-lg border border-slate-200 p-4 hover:border-slate-300&quot;&gt;
      &lt;h3 className=&quot;text-sm font-semibold text-slate-900&quot;&gt;{policy.name}&lt;/h3&gt;
      &lt;p className=&quot;mt-1 text-xs text-slate-500&quot;&gt;{policy.status}&lt;/p&gt;
    &lt;/article&gt;
  );
}
</code></pre>
<p>The first reaction most people have is &quot;that&#39;s ugly, the markup is noisy.&quot; Fair. I had the same reaction. Then I worked in codebases where Tailwind had been the standard for a year, and the noise stopped mattering. Here&#39;s what I noticed instead:</p>
<ul>
<li><strong>Nobody invents new spacing values.</strong> <code>p-4</code> is <code>p-4</code> everywhere. The design constraints are baked into the class names, so drift is hard.</li>
<li><strong>Deleting a component deletes its styles.</strong> No orphaned CSS rotting in a global file because everyone&#39;s afraid to remove it.</li>
<li><strong>No naming tax.</strong> I don&#39;t spend energy inventing <code>.policy-card__status--pending</code>. The cognitive cost of good CSS class names is real, and Tailwind just removes it.</li>
<li><strong>It compiles away.</strong> The output is a static stylesheet. Zero runtime style computation, which means it plays nicely with Server Components.</li>
</ul>
<p>The trade-offs are real too, and I won&#39;t pretend otherwise. The markup is genuinely harder to scan at first. Long class strings need something like <code>clsx</code> or <code>tailwind-merge</code> once conditions appear. And onboarding someone who&#39;s never used it costs a day or two of &quot;why is everything a class name.&quot;</p>
<p>The reservation I take most seriously: Tailwind is weak when a style is genuinely <em>dynamic</em> — driven by a value only known at runtime, like a chart bar&#39;s height from data. You don&#39;t want <code>h-[347px]</code> generated on the fly. For that, an inline style or a CSS variable is the right tool, and mixing them in is fine. Tailwind for the static 95%, a CSS variable for the dynamic 5%.</p>
<p>That&#39;s my honest position: Tailwind wins on team-scale maintainability, and that&#39;s the axis I care about most. But &quot;it wins for me&quot; is not &quot;it wins for everyone,&quot; so let me give the alternatives a real hearing.</p>
<h2>CSS Modules: The Boring, Correct Answer</h2>
<p>If Tailwind&#39;s class soup is a dealbreaker for you, CSS Modules is the option I respect the most.</p>
<pre><code class="language-tsx">import styles from &#39;./PolicyCard.module.css&#39;;

function PolicyCard({ policy }: { policy: Policy }) {
  return (
    &lt;article className={styles.card}&gt;
      &lt;h3 className={styles.title}&gt;{policy.name}&lt;/h3&gt;
      &lt;p className={styles.status}&gt;{policy.status}&lt;/p&gt;
    &lt;/article&gt;
  );
}
</code></pre>
<pre><code class="language-css">/* PolicyCard.module.css */
.card {
  border-radius: 0.5rem;
  border: 1px solid var(--color-border);
  padding: 1rem;
}
</code></pre>
<p>What I like: it&#39;s just CSS. No new mental model, no runtime, locally scoped class names so you can&#39;t leak styles across the app. It compiles to a static stylesheet, so like Tailwind it&#39;s Server Components-friendly. If a team has strong CSS skills and hates utility classes, this is not a compromise — it&#39;s a genuinely solid choice.</p>
<p>What pushes me away from it at scale: it doesn&#39;t enforce consistency on its own. Nothing stops one file from using <code>padding: 16px</code> and another using <code>padding: 1rem</code> and a third using <code>padding: 14px</code> because someone eyeballed it. You get scoping for free, but you have to build the discipline — the tokens, the shared variables — yourself. Tailwind ships that discipline in the box. That&#39;s the whole difference, and for some teams CSS Modules plus a well-guarded token file is exactly right.</p>
<h2>CSS-in-JS: Why I&#39;m Walking Away From It</h2>
<p>This is the one where I&#39;ve changed my mind, so I&#39;ll say it plainly.</p>
<p>I used to reach for styled-components and Emotion without a second thought. The developer experience is lovely: styles co-located with the component, full access to props, dynamic styling that feels natural.</p>
<pre><code class="language-tsx">const Card = styled.article&lt;{ $pending: boolean }&gt;`
  border-radius: 0.5rem;
  padding: 1rem;
  border: 1px solid ${(p) =&gt; (p.$pending ? &#39;#f59e0b&#39; : &#39;#e2e8f0&#39;)};
`;
</code></pre>
<p>That reads great. So why am I walking away?</p>
<p>Because the cost moved. Runtime CSS-in-JS computes styles in the browser, on render, and serializes them into the DOM as the app runs. For years that cost was acceptable. Then React Server Components arrived, and runtime CSS-in-JS sits awkwardly with them — these libraries lean on runtime context and client-side execution, which is exactly what Server Components are trying to avoid. The React team&#39;s own guidance nudged people toward compile-time styling for a reason.</p>
<p>I don&#39;t say this to bury a whole category — if you&#39;re on a heavily client-rendered app and the DX makes your team faster, runtime CSS-in-JS is not a crime. But for new work in 2026, especially anything touching Server Components, I no longer start here. The direction of the ecosystem is away from runtime style computation, and I&#39;d rather not build on the side that&#39;s swimming against it.</p>
<p>Which leads directly to the interesting middle ground.</p>
<h2>Zero-Runtime CSS-in-JS: The Best of Both, Mostly</h2>
<p>Tools like vanilla-extract keep the part of CSS-in-JS people actually love — writing styles in TypeScript, with type-safe tokens — while throwing away the part that hurts: the runtime.</p>
<pre><code class="language-ts">// PolicyCard.css.ts
import { style } from &#39;@vanilla-extract/css&#39;;
import { tokens } from &#39;../theme.css&#39;;

export const card = style({
  borderRadius: tokens.radius.md,
  border: `1px solid ${tokens.color.border}`,
  padding: tokens.space.md,
});
</code></pre>
<p>You author in TypeScript, get autocomplete and type-checking on your design tokens, and it all compiles to a static stylesheet at build time. Zero runtime. Server Components-friendly. Honestly, this is the most intellectually satisfying option on the list — it fixes the exact thing that made me leave runtime CSS-in-JS.</p>
<p>So why isn&#39;t it my default? Two reasons, and neither is technical. The ecosystem and hiring pool around Tailwind are enormous, and the token-based type safety that vanilla-extract gives you, Tailwind approximates well enough through its config. If your team already thinks in TypeScript tokens and wants that safety enforced by the compiler, vanilla-extract is a genuinely excellent pick — arguably the &quot;correct&quot; one on paper. I just find Tailwind gets a team to the same maintainability with less setup.</p>
<h2>The Thread That Ties It Together: Tokens</h2>
<p>Notice what quietly showed up in almost every example above — <code>var(--color-border)</code>, <code>tokens.space.md</code>, Tailwind&#39;s <code>slate-200</code>. That&#39;s the part that actually matters, and it&#39;s independent of which library you pick.</p>
<blockquote>
<p>The styling library is a delivery mechanism. Design tokens are the actual system.</p>
</blockquote>
<p>Whatever you choose, the thing that keeps a large app coherent is a single source of truth for color, spacing, radius, and typography — and the discipline to never bypass it with a magic number. Tailwind gives you tokens through its config. Vanilla-extract gives you tokens as typed objects. CSS Modules gives you tokens through CSS custom properties. The delivery differs; the principle doesn&#39;t. If you get tokens right, you can survive changing your styling library later. If you get them wrong, no library saves you.</p>
<h2>So What Do I Actually Pick?</h2>
<p>Stripped of tribalism, here&#39;s my honest decision path:</p>
<ul>
<li><strong>New app, normal team, wants to move fast and stay consistent</strong> → Tailwind. It ships the constraints in the box.</li>
<li><strong>Team with strong CSS culture that dislikes utility classes</strong> → CSS Modules plus a guarded token file. Boring and correct.</li>
<li><strong>Team that lives in TypeScript and wants compiler-enforced tokens</strong> → vanilla-extract. The most &quot;principled&quot; pick.</li>
<li><strong>Heavily client-rendered app where runtime DX wins</strong> → runtime CSS-in-JS is still defensible, eyes open about Server Components.</li>
</ul>
<p>There&#39;s no universally right answer, and anyone who tells you there is hasn&#39;t worked in enough different codebases. For this series I&#39;ll use Tailwind, because it fits the kind of app we&#39;re building and it pairs cleanly with what comes next.</p>
<p>Because styling rarely lives alone. The moment you have a real app, you reach for a component library — and how it&#39;s styled decides whether you can theme it, override it, or fight it. That&#39;s the next article: <a href="https://ma-x.im/blog/react-playbook-ui-libraries">choosing a React UI library</a>, and why the styling decision you just made quietly constrains that choice.</p>
<p>If your team is mid-argument about this right now, try changing the question. Stop asking which one is best and start asking which one your codebase can live with in two years. And if you&#39;ve landed somewhere I&#39;d disagree with — especially if you&#39;re still happily on runtime CSS-in-JS — I genuinely want to hear the case. I&#39;ve changed my mind on this before.</p>
]]></content:encoded>
      <pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>CSS</category>
      <category>Tailwind</category>
      <category>Styling</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Immutable Data in React: The Rule I Stopped Arguing About</title>
      <link>https://ma-x.im/blog/react-playbook-immutable-data-structures</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-immutable-data-structures</guid>
      <description>A module I was told not to over-engineer grew into an entire application. Most of what let it survive was boring — and one of the most boring rules was never mutating state in place. Here&apos;s how immutability actually works in React, when I reach for Immer, and why I skip Immutable.js.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-immutable-data-structures/cover.png" alt="Immutable Data in React: The Rule I Stopped Arguing About" /></p>
<p>The company had a legacy PHP application, and we were not brought in to rebuild it. We were brought in to add one module.</p>
<p>One module. A self-contained piece of the product that a couple of teams needed. Nobody expected it to matter much beyond that.</p>
<p>So when I pushed for structure — a real data model, immutable updates, clear boundaries — there was pushback. Fair pushback. &quot;It&#39;s one module. Why the ceremony? Why can&#39;t we just mutate the thing and move on?&quot;</p>
<p>I understood the argument. On a small, disposable module, discipline can look like pure overhead. You feel every extra rule slowing you down long before it pays you back.</p>
<p>Then the module did the thing small modules occasionally do. It got good. It got used. Other teams started building against it. Over a couple of years it quietly stopped being a module and became the main application, with almost everything bolted onto the shape we set at the start. I still talk to the people there. They are still building on that foundation today.</p>
<p>I&#39;m not telling this to claim we were geniuses. We weren&#39;t. Most of what held up under that growth was boring. And one of the most boring rules of all was this: we never mutated state in place.</p>
<p>That&#39;s what this article is about. Immutable data in React — not the purity lecture, but the practical discipline. Where it bites you if you skip it, when the native approach stops being pleasant, and how it connects to the reactive model from the <a href="https://ma-x.im/blog/react-playbook-reatom-state-management">Reatom article</a> earlier in this series.</p>
<p>If you know JavaScript, you already know how to copy an object. That&#39;s not the hard part. The hard part is understanding <em>why</em> React, memoization, and reactive stores punish you the moment you forget.</p>
<h2>What Mutation Actually Breaks</h2>
<p>React doesn&#39;t deeply compare your state. It compares references.</p>
<p>That single sentence is the whole thing. Everything else is a consequence of it.</p>
<p>Here&#39;s a bug I&#39;ve watched people write, debug for twenty minutes, and never fully explain afterward:</p>
<pre><code class="language-tsx">const [draft, setDraft] = useState&lt;PolicyDraft&gt;(initialDraft);

function addBeneficiary(beneficiary: BeneficiaryDraft) {
  draft.beneficiaries.push(beneficiary); // mutation
  setDraft(draft);                        // same reference
}
</code></pre>
<p>You added a beneficiary. The data changed. The screen didn&#39;t.</p>
<p>React ran <code>Object.is(previousDraft, nextDraft)</code>, saw the same object reference, and decided nothing changed. Your <code>push</code> mutated the array in place, so the outer <code>draft</code> object is still the exact same reference it was before. As far as React is concerned, you called <code>setDraft</code> with the value it already had.</p>
<p>The fix isn&#39;t a trick. It&#39;s a new reference:</p>
<pre><code class="language-tsx">function addBeneficiary(beneficiary: BeneficiaryDraft) {
  setDraft({
    ...draft,
    beneficiaries: [...draft.beneficiaries, beneficiary],
  });
}
</code></pre>
<p>New object, new array. React compares references, sees a difference, re-renders.</p>
<p>This is the React nuance the JS-fundamentals tutorials skip. Immutability in React isn&#39;t about being functionally pure for its own sake. It&#39;s that the entire rendering and memoization model is built on the assumption that <em>a change produces a new reference</em>. Break that assumption and everything downstream — <code>React.memo</code>, <code>useMemo</code>, dependency arrays, reactive stores — quietly stops working. Not with an error. With silence.</p>
<blockquote>
<p>Mutation doesn&#39;t crash. It just makes your UI lie about the data.</p>
</blockquote>
<h2>The Rule: A New Reference On Every Change</h2>
<p>The rule is boring, and boring is the point:</p>
<p><strong>Never change data in place. Produce a new value.</strong></p>
<p>For flat, shallow state, the native tools are enough, and I reach for them first.</p>
<p>Add an item:</p>
<pre><code class="language-ts">const next = [...beneficiaries, newBeneficiary];
</code></pre>
<p>Remove by id:</p>
<pre><code class="language-ts">const next = beneficiaries.filter((b) =&gt; b.id !== removedId);
</code></pre>
<p>Update one item by id — the operation people get wrong most often:</p>
<pre><code class="language-ts">const next = beneficiaries.map((b) =&gt;
  b.id === targetId ? { ...b, share: newShare } : b,
);
</code></pre>
<p>Notice what <code>map</code> does here. Every item that didn&#39;t change keeps its original reference. Only the one you touched becomes a new object. That&#39;s not an accident — it&#39;s exactly what you want. Components rendering the untouched rows can skip re-rendering, because their props are referentially identical.</p>
<p>Update a field on an object:</p>
<pre><code class="language-ts">const next = { ...draft, holderName: &#39;Updated Name&#39; };
</code></pre>
<p>None of this is clever. That&#39;s the appeal. Anyone reading it can see what changed and what stayed the same.</p>
<h2>Where Native Updates Get Painful</h2>
<p>Then the state gets nested, and the spreads start to hurt.</p>
<p>Say the policy draft looks like this:</p>
<pre><code class="language-ts">interface PolicyDraft {
  holderName: string;
  sections: {
    coverage: {
      type: &#39;standard&#39; | &#39;premium&#39;;
      limits: {
        medical: number;
        liability: number;
      };
    };
  };
}
</code></pre>
<p>Now update <code>limits.medical</code> immutably with native syntax:</p>
<pre><code class="language-ts">const next = {
  ...draft,
  sections: {
    ...draft.sections,
    coverage: {
      ...draft.sections.coverage,
      limits: {
        ...draft.sections.coverage.limits,
        medical: newValue,
      },
    },
  },
};
</code></pre>
<p>Technically correct. Practically miserable. You changed one number and wrote a five-level spread pyramid to do it. Worse, in a code review nobody can see at a glance <em>what actually changed</em> — the signal is buried under structural boilerplate. And every one of those spread levels is a place to make a typo that silently drops a branch of state.</p>
<p>This is the moment people conclude &quot;immutability is exhausting&quot; and start looking for shortcuts. Some of those shortcuts are good. One of them is a mistake.</p>
<h2>Immer: The Escape Hatch for Deep Updates</h2>
<p>When updates get deep, I reach for <a href="https://immerjs.github.io/immer/">Immer</a>.</p>
<p>Immer lets you write code that <em>looks</em> like mutation and hands you an immutable result:</p>
<pre><code class="language-ts">import { produce } from &#39;immer&#39;;

const next = produce(draft, (d) =&gt; {
  d.sections.coverage.limits.medical = newValue;
});
</code></pre>
<p>That&#39;s the same nested update as above. One line. You mutate a temporary draft object; Immer records the changes and produces a new immutable value, reusing every branch you didn&#39;t touch. That last part matters: the <code>holderName</code> string and the untouched <code>liability</code> limit keep their references, so referential-equality checks and memoization still work. You get the ergonomics of mutation with the guarantees of immutability.</p>
<p>My rule of thumb is narrow on purpose:</p>
<ul>
<li><strong>Flat or shallow state → native spreads.</strong> Don&#39;t pull in Immer to change one field on a flat object. It&#39;s noise.</li>
<li><strong>Deep or awkwardly nested updates → Immer.</strong> The moment the spread pyramid appears, <code>produce</code> earns its place.</li>
</ul>
<p>Immer isn&#39;t free. It wraps your drafts in proxies and freezes output in development, which is a real runtime cost. For application state — forms, drafts, workflow models — that cost is invisible. I would not reach for it inside a hot render loop or a tight animation frame. Right tool, right layer.</p>
<h2>Why I Skip Immutable.js</h2>
<p>Every time this topic comes up, someone asks about <a href="https://immutable-js.com/">Immutable.js</a>. It gives you <code>Map</code>, <code>List</code>, and structural sharing baked in, so surely it&#39;s the &quot;serious&quot; choice?</p>
<p>Honestly, no. Not in a modern React codebase, and not for me.</p>
<p>The problem isn&#39;t the data structures. They&#39;re well made. The problem is that Immutable.js introduces <em>a parallel type system</em> that doesn&#39;t speak plain JavaScript:</p>
<pre><code class="language-ts">const draft = Map({ holderName: &#39;Ann&#39;, beneficiaries: List() });

const name = draft.get(&#39;holderName&#39;);        // not draft.holderName
const next = draft.set(&#39;holderName&#39;, &#39;Bob&#39;); // not { ...draft }
const plain = draft.toJS();                  // needed at every boundary
</code></pre>
<p>That <code>toJS()</code> at the boundary is the tell. Your API returns plain objects. TanStack Query caches plain objects. TanStack Form, most component libraries, your logging, your TypeScript types — all assume plain objects. So you spend your life converting in and out, and every conversion is a place for bugs and lost type information. The TypeScript ergonomics are genuinely worse than working with plain interfaces.</p>
<p>Native immutable updates plus Immer for the deep cases cover everything Immutable.js was solving, without forcing the entire codebase to adopt a second notion of what &quot;an object&quot; is. I stopped considering it years ago and haven&#39;t missed it once.</p>
<h2>Immutability and Reatom</h2>
<p>This is where the previous article and this one meet.</p>
<p>In the <a href="https://ma-x.im/blog/react-playbook-reatom-state-management">Reatom piece</a>, the whole model was a graph: small atoms, derived atoms, actions as transitions. That graph runs on the exact same assumption React does — <strong>a change is a new reference.</strong> Reatom decides what to recompute and what to re-render by comparing atom values by reference. Mutate an atom&#39;s value in place and you reproduce the React bug one layer down: derived atoms don&#39;t recompute, subscribed components don&#39;t update.</p>
<p>So the wrong version:</p>
<pre><code class="language-ts">export const addBeneficiary = action((ctx, beneficiary: BeneficiaryDraft) =&gt; {
  const draft = ctx.get(policyDraftAtom);
  draft.beneficiaries.push(beneficiary); // mutation
  policyDraftAtom(ctx, draft);           // same reference — nothing reacts
}, &#39;addBeneficiary&#39;);
</code></pre>
<p>And the right one:</p>
<pre><code class="language-ts">export const addBeneficiary = action((ctx, beneficiary: BeneficiaryDraft) =&gt; {
  const draft = ctx.get(policyDraftAtom);

  policyDraftAtom(ctx, {
    ...draft,
    beneficiaries: [...draft.beneficiaries, beneficiary],
  });
}, &#39;addBeneficiary&#39;);
</code></pre>
<p>And when the transition is deep, Immer lives comfortably inside an action:</p>
<pre><code class="language-ts">import { produce } from &#39;immer&#39;;

export const setMedicalLimit = action((ctx, medical: number) =&gt; {
  const draft = ctx.get(policyDraftAtom);

  policyDraftAtom(ctx, produce(draft, (d) =&gt; {
    d.sections.coverage.limits.medical = medical;
  }));
}, &#39;setMedicalLimit&#39;);
</code></pre>
<p>Immutability isn&#39;t a nice-to-have bolted onto the state graph. It&#39;s the thing that makes the graph work at all. The reactivity you get from Reatom is <em>paid for</em> by never mutating in place.</p>
<h2>The Payoff</h2>
<p>Everything so far sounds like rules. Here&#39;s what the rules buy you, and it&#39;s the reason that small module survived becoming a large application.</p>
<p><strong>Memoization actually works.</strong> Because unchanged branches keep their references, <code>React.memo</code> and derived atoms skip work honestly. You don&#39;t fight spurious re-renders, because your data stops lying about what changed.</p>
<p><strong>Undo becomes boring.</strong> The <a href="https://ma-x.im/blog/react-playbook-reatom-state-management">undo history from the Reatom article</a> only works because every previous draft is a distinct, frozen-in-time reference. If you mutated in place, &quot;history&quot; would just be a list of pointers to the same object, all mutating together. Immutability is what makes a snapshot an actual snapshot.</p>
<pre><code class="language-ts">const history: PolicyDraft[] = [];

// every entry is a real, independent snapshot — because we never mutate
history.push(currentDraft);
policyDraftAtom(ctx, nextDraft);
</code></pre>
<p><strong>Optimistic updates are safe.</strong> You apply the optimistic value as a new reference, keep the previous one, and roll back to it if the request fails. No cloning gymnastics, no fear that the rollback value mutated underneath you.</p>
<p><strong>Debugging tells the truth.</strong> A logged state object stays what it was when you logged it. No more &quot;it was correct in the console but wrong in the UI&quot; — the classic symptom of an alias mutating after you inspected it.</p>
<p>None of these are exotic. They&#39;re the everyday properties that let a codebase absorb requirements it wasn&#39;t designed for. Which is exactly what that module had to do.</p>
<h2>The Rule I Stopped Arguing About</h2>
<p>The early pushback was really about ceremony. Immutability <em>looked</em> like ceremony — extra rules, extra keystrokes, extra discipline for a module nobody thought would grow.</p>
<p>But the module grew, and the ceremony turned out to be the cheapest insurance we bought. Not because immutability is profound. Because it removed an entire category of bug — the silent, intermittent, &quot;works until it doesn&#39;t&quot; kind — right at the point where scale makes those bugs most expensive to find.</p>
<p>I don&#39;t argue about immutability anymore. When someone asks whether it&#39;s worth the trouble on a small piece of code, I&#39;ve stopped debating it. I just don&#39;t mutate. The habit costs almost nothing, and it holds up under exactly the pressure you can&#39;t predict when you&#39;re writing &quot;just one module.&quot;</p>
<p>Next in the series, we move from data to what the user actually sees: <a href="https://ma-x.im/blog/react-playbook-css-styling">styling in React</a>, and why I keep coming back to Tailwind after trying most of the alternatives.</p>
<p>If you&#39;ve got a state bug that only shows up sometimes — the kind that vanishes the moment you add a <code>console.log</code> — go looking for something mutating in place. And if you want to compare notes on where Immer genuinely earns its keep versus where it&#39;s overkill, reach out. I have opinions, and I&#39;m happy to hear yours.</p>
]]></content:encoded>
      <pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Immutability</category>
      <category>Reatom</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Give Your AI Agent a Memory — It Will Stop Disappointing You</title>
      <link>https://ma-x.im/blog/give-your-ai-agent-a-memory</link>
      <guid isPermaLink="true">https://ma-x.im/blog/give-your-ai-agent-a-memory</guid>
      <description>A simple file-based memory system that turns AI agents from forgetful interns into reliable collaborators. Works for engineers, designers, writers, and anyone who uses AI tools daily.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/give-your-ai-agent-a-memory/cover.png" alt="Give Your AI Agent a Memory — It Will Stop Disappointing You" /></p>
<p>I was mass-scrolling LinkedIn and almost missed it. A <a href="https://www.linkedin.com/posts/dmytro-serg-909385b5_%D1%89%D0%BE%D1%80%D0%B0%D0%B7%D1%83-%D0%BF%D0%BE%D1%8F%D1%81%D0%BD%D1%8E%D1%94%D1%88-ai-%D0%B0%D0%B3%D0%B5%D0%BD%D1%82%D1%83-%D0%BE%D0%B4%D0%BD%D0%B5-%D0%B9-%D1%82%D0%B5-%D1%81%D0%B0%D0%BC%D0%B5-activity-7440664293827567616-i37k/?utm_source=ma-x.im&utm_medium=member_desktop&rcm=ACoAAARw5RMBaP7bUQuqKf2SLQvB7dtX0aJT9Fo">post from Dmytro Sergiev</a> — a Product Manager, not an engineer — about how he gives his AI agent a persistent memory using plain folders and markdown files. And something clicked.</p>
<p>I had been annoyed for months. Every new chat session with an AI agent — same questions, same wrong assumptions, same &quot;what framework are you using?&quot; dance. I would explain my project setup, my conventions, my stack. The agent would do decent work. Next day — blank slate. All of it, gone. I knew the problem was not the model itself — I understood that AI needs context to be useful. But I could not figure out a sustainable way to give it that context. The problem was amnesia.</p>
<p>Dmytro&#39;s idea was dead simple: give the agent a folder it can read and write between sessions. I grabbed this concept, adapted it for my frontend engineering workflow, and started rolling it out across every project I work on. It has been a few weeks now, and honestly — I cannot believe I worked without this for so long.</p>
<blockquote>
<p><strong>Update — April 2026:</strong> This post describes v1 of the system. After about three weeks of real use — across multiple projects and a team discussion — I ran into the limits of the append-only approach and redesigned it from scratch. The core idea here still holds and is worth reading for context. The hardened version is in <a href="https://ma-x.im/blog/ai-agent-memory-redesign">The AI Agent Memory System I Built Was Broken — Here Is the Redesign</a>.</p>
</blockquote>
<h2>The Problem Is Not the Model — It Is the Amnesia</h2>
<p>Think about what happens when you open a fresh chat and say &quot;add a blog post to my site.&quot; The agent has no idea what framework you use. It does not know how your content is structured. It does not know you spent two hours last Tuesday figuring out that your blog posts are TypeScript files with markdown in a template literal. It does not know that you tried CSS-in-JS once and it was a disaster.</p>
<p>So it guesses. You correct. It guesses again. You correct again. Twenty minutes pass before any real work happens. And tomorrow? Same thing. From zero.</p>
<p>I always understood that the model needs context. That was never the question. The question was — how do you actually give it enough? Context is not a document you write once. It is years of decisions, conventions, mistakes, preferences — all living in your head. You cannot dump all of that into a prompt. And even if you could, it would be out of date by next week. I needed a system, not a one-time fix.</p>
<h2>The Fix: A Folder the Agent Can Read and Write</h2>
<p>Create a <code>.ai-memory/</code> folder in your project root. Put a few markdown files in it. Tell the agent to read them at the start of every session and update them as it learns. Seriously, that is the whole idea.</p>
<pre><code class="language-markdown">.ai-memory/
  context.md       — What this project is, who works on it, goals, constraints
  decisions.md     — Key decisions made, with reasoning
  patterns.md      — Conventions, naming rules, recurring patterns
  lessons.md       — Mistakes, gotchas, things to avoid
  sessions/
    2026-03-25.md  — What was done today, what was learned
    2026-03-24.md  — Yesterday&#39;s session
</code></pre>
<p>Five files and a folder. No database. No API. No npm packages. Just markdown files that a human can read too.</p>
<h2>What Goes in Each File</h2>
<p><strong>context.md</strong> is the project&#39;s identity card. Who you are, what the project does, the tech stack, the constraints. For a React project it might say &quot;Next.js 15, App Router, Tailwind, deployed to Vercel.&quot; For a marketing project it might say &quot;Brand guidelines in /docs, tone is professional but warm, target audience is B2B SaaS.&quot; For an accounting workflow it could describe the tools, the report formats, the compliance rules.</p>
<p><strong>decisions.md</strong> captures the &quot;why&quot; behind choices. Not just &quot;we use Tailwind&quot; but &quot;we use Tailwind because utility-first is fast for a small team and avoids CSS naming debates.&quot; When the agent encounters a similar decision later, it does not propose something contradictory.</p>
<p><strong>patterns.md</strong> records how things are done in this specific project. File naming conventions. Component structure. How blog posts are stored. Where images go. The things a new team member would ask on their first day — except the agent finds them on its own.</p>
<p><strong>lessons.md</strong> is the file that surprised me the most. I did not expect it to matter this much. Every bug, every gotcha, every &quot;do not do this because...&quot; entry prevents the agent from repeating the same mistake. I had a bug in one of my projects where backticks inside a template literal were breaking the build. I fixed it, moved on, and the next day the agent generated the exact same bug. After I added &quot;backticks inside template literal content must be escaped&quot; to lessons.md — never happened again. Three words in a markdown file saved me from debugging the same issue three times.</p>
<p><strong>sessions/</strong> logs keep a running history. After each session the agent writes what it did, what decisions were made, what was learned. These logs feed future sessions with recent context.</p>
<h2>Why This Works for Everyone — Not Just Engineers</h2>
<p>Here is what struck me about Dmytro&#39;s original post: he is not a developer. He is a Product Manager who works with data, strategy, and AI-assisted research. He does not write React components. He uses VS Code as a workspace for documents and workflows. And the memory system works just as well for him — maybe better, because his context is even harder for an agent to guess.</p>
<p>Think about an analyst who works with multiple data sources, specific report formats, metric definitions that differ between clients, and compliance rules that change quarterly. Every time they start a new AI session, they would have to re-explain all of that. With a memory folder, the agent already knows: &quot;Client X uses quarterly cohort analysis, reports go in /reports/YYYY-QN/, retention is measured as 30-day active, not 30-day login.&quot; That is not code. That is just domain knowledge written in plain text. And it makes the agent dramatically more useful.</p>
<p>The same applies to product managers tracking sprint context, designers recording spacing conventions and color tokens, writers maintaining tone guidelines. The files are plain markdown. No code required. If you can write a bullet point, you can give your agent a memory.</p>
<h2>The Practical Difference</h2>
<p>Let me give you a real example. Last week I came back to a project I had not touched in six days. Old me would have spent the first 15 minutes re-explaining the setup: &quot;This is a Next.js site, blog posts are TypeScript files, we use Tailwind, content goes in src/data/posts...&quot; Instead, I just said &quot;create a new blog post about agent memory.&quot; The agent read context.md and patterns.md, understood the file structure, the naming conventions, the BlogPost type — and produced a working post file on the first try. Fifteen minutes of setup reduced to zero.</p>
<p>Or this one: I kept correcting the agent — &quot;we use Tailwind, not CSS modules.&quot; Every. Single. Session. After I wrote it in decisions.md, that correction disappeared permanently. A one-line entry fixed a daily annoyance.</p>
<p>The thing that gets me is how it compounds. After a week, the agent knows your project better than a new team member after onboarding. After a month, going back to a project without memory feels broken — like opening a codebase with no README, no comments, nothing. You realize how much invisible context you were carrying in your head and re-typing every day.</p>
<h2>How to Set This Up in Any Project</h2>
<p>Here is the step-by-step. Takes about five minutes.</p>
<p><strong>Step 1:</strong> Create the folder structure in your project root:</p>
<pre><code class="language-bash">mkdir .ai-memory
mkdir .ai-memory/sessions
</code></pre>
<p><strong>Step 2:</strong> Create the instruction file. If you use VS Code with GitHub Copilot, put this in <code>.github/copilot-instructions.md</code>. If you use Claude/Cursor, put it in <code>.cursorrules</code> or the equivalent config file.</p>
<p>Here is the full prompt you can copy:</p>
<pre><code class="language-markdown"># Agent Memory System

You have access to a persistent memory system stored in .ai-memory/
at the root of this project. Use it to maintain context across sessions,
avoid asking repeated questions, and make better decisions over time.

## Memory Structure

.ai-memory/
  context.md       — Project identity, tech stack, goals, constraints
  decisions.md     — Key technical decisions with reasoning
  patterns.md      — Conventions, naming patterns, project-specific rules
  lessons.md       — Mistakes, gotchas, things to remember and avoid
  sessions/
    YYYY-MM-DD.md  — Session log: what was done, decided, learned

## Rules

### At the Start of Every Session
1. Read context.md, decisions.md, patterns.md, and lessons.md.
2. Use this information to inform your work. Do not ask questions
   already answered in these files.
3. If a file does not exist yet, create it when you have information
   worth recording.

### During Work
- Non-trivial decisions -&gt; record in decisions.md
- Discovered conventions -&gt; record in patterns.md
- Bugs, gotchas, mistakes -&gt; record in lessons.md
- Project goals or constraints -&gt; update context.md

### Mandatory Memory Checkpoints
You MUST update memory at these moments. Do not skip. Do not defer.
1. After fixing a bug or discovering a gotcha -&gt; add to lessons.md immediately.
2. After completing a multi-step task -&gt; update relevant memory files right away.
3. When the user shares a preference or constraint -&gt; update context.md or patterns.md within the same response.
4. Before responding with &quot;done&quot; -&gt; check if anything should be in memory first.

### After Significant Work
- Create or append to sessions/YYYY-MM-DD.md with:
  what was done, key decisions, lessons learned.

### Writing Style
- Concise: 1-3 sentences per entry
- Factual: what was decided, not how you feel
- Append-friendly: new entries at the bottom
- No duplication: update existing entries, do not contradict

### If .ai-memory/ Does Not Exist
Create the folder and initial files by scanning the project:
package.json, README, configs -&gt; context.md and patterns.md.
Leave decisions.md and lessons.md mostly empty — they grow over time.
</code></pre>
<p><strong>Step 3:</strong> Start a new session. The agent will read the instruction, create the initial memory files by scanning your project, and begin maintaining them automatically.</p>
<p><strong>Step 4:</strong> Decide where memory lives.</p>
<p>If this is your personal project — commit <code>.ai-memory/</code> to the repo. You are the only one working here, so your memory is the project&#39;s memory. That is how I do it on ma-x.im.</p>
<p>If you work in a team — even three developers — add <code>.ai-memory/</code> to <code>.gitignore</code>. Memory is personal. Your thinking, your mistakes, your patterns — they are different from your colleague&#39;s. General project knowledge (architecture, API conventions, component rules) belongs in shared context files like <code>.github/copilot-instructions.md</code>. But memory is your private notebook. The instruction prompt stays in the repo so every developer&#39;s agent knows to create and maintain its own local memory — but the memory content itself stays local.</p>
<p>There is a possible third path: shared team memory where everyone writes to the same folder. I can see how it could work in theory, but I have not seen a strong use case for it yet. Shared context plus individual memory covers most scenarios.</p>
<h2>Honest Limitations</h2>
<p>This is not magic. A few things to keep in mind:</p>
<ul>
<li><strong>The agent forgets to update memory.</strong> This was my biggest surprise. I wrote clear instructions: &quot;after fixing a bug, record it in lessons.md.&quot; The agent would fix the bug, confirm it was done, and move on without writing anything down. Hours of work, zero memory updates. The fix was adding explicit mandatory checkpoints to the instructions — not &quot;record things when appropriate&quot; but &quot;you MUST update memory BEFORE telling me you are done.&quot; Passive instructions get ignored during complex multi-step work. Forceful, specific checkpoints work.</li>
<li><strong>Files can get bloated.</strong> If lessons.md grows past 100 lines, split it or prune old entries. The agent reads these files every session — keep them focused.</li>
<li><strong>It depends on the agent following instructions.</strong> Some AI tools respect system prompts better than others. GitHub Copilot in VS Code and Claude in Cursor both handle this well. Other tools may vary.</li>
<li><strong>It is not a replacement for proper documentation.</strong> This is a working notebook, not a wiki. Keep real docs in real docs.</li>
</ul>
<h2>Context vs Memory — Why You Need Both</h2>
<p>This is the distinction I wish someone had explained to me earlier.</p>
<p><strong>Context</strong> is what describes the project: architecture, API structure, component conventions, CSS rules, TypeScript config, deployment pipeline. It is documentation. You write it once, maintain it as the project evolves, and every developer on the team reads the same version. Context lives in shared files — README, copilot-instructions.md, architecture docs.</p>
<p><strong>Memory</strong> is what describes the developer: your decisions, your mistakes, your patterns, your working style. It grows organically, session by session. What you did today, what went wrong yesterday, what you learned this week — this is what makes the agent smarter over time. Context is static knowledge. Memory is accumulated experience.</p>
<p>In an ideal project, you need both. Context so the agent understands the codebase. Memory so it understands you. Without context, the agent guesses about your stack. Without memory, it repeats the same mistakes and asks the same questions every day.</p>
<p>The real shift for me was understanding that context alone is never enough. There is always more knowledge in your head than you can write down — it forms over months and years of working on a project. You cannot capture all of it upfront. The memory approach solves this differently: instead of trying to dump everything at once, it lets knowledge accumulate naturally. Every session teaches the agent something new. After a week it knows your project better than a new hire after onboarding. After a month, working without it feels broken.</p>
<h2>Summary</h2>
<p>I always knew the model needed context. That was never the mystery. What I could not figure out was how to give it enough context without spending half my day writing documentation that would be outdated by Friday. The memory approach solved this — not by asking me to write everything upfront, but by letting knowledge accumulate naturally, session by session.</p>
<p>Five markdown files and a prompt. Five minutes of setup. The return is immediate and it compounds — every session makes the next one smarter.</p>
<p>The people chasing the newest model are solving the wrong problem. The leverage is not in the model. It is in what the model knows about you and your project.</p>
<p>If you have feedback, alternative approaches, or just want to discuss this topic — feel free to reach out.</p>
]]></content:encoded>
      <pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate>
      <category>AI</category>
      <category>Productivity</category>
      <category>DX</category>
      <category>Workflow</category>
    </item>
    <item>
      <title>React State Management with Reatom: When useState Stops Being Enough</title>
      <link>https://ma-x.im/blog/react-playbook-reatom-state-management</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-reatom-state-management</guid>
      <description>Most React state doesn&apos;t need a library. Then one workflow grows into drafts, derived values, undo, optimistic steps, and cross-component coordination. This is where I reach for Reatom instead of turning TanStack Query into a client-state store.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-reatom-state-management/cover.png" alt="React State Management with Reatom: When useState Stops Being Enough" /></p>
<p>The policy builder did not look dangerous at first.</p>
<p>It was &quot;just a form.&quot; A few sections, some conditional fields, a Save Draft button, a final Submit action. We already had TanStack Query for server state and TanStack Form for field state. It felt like the rest could live in component state.</p>
<p>Then the requirements arrived one by one.</p>
<p>If the user changed coverage type, three sections had to recalculate. If they removed a beneficiary, dependent warnings had to disappear. If they switched tabs, the draft had to stay alive. If the API returned a validation warning, it had to attach to a section that might not be mounted. If they clicked Review, the app had to derive a summary from fields, rules, permissions, and unsaved changes.</p>
<p>Nothing was individually hard. Together, it became a graph.</p>
<p>I&#39;ve been in this room before. Different company, different domain, same shape. A workflow starts as local state. Then you add a context. Then a reducer. Then a few selectors. Then effects hidden in components. Eventually nobody can answer a simple question: <em>when this value changes, what else reacts?</em></p>
<p>That is the point where I want a state management library.</p>
<p>Not for every boolean. Not for every modal. For the moment client state becomes a reactive model instead of a component detail.</p>
<blockquote>
<p>The question is not &quot;do we need global state?&quot; The question is &quot;does this workflow have a state graph?&quot;</p>
</blockquote>
<p>This is article #7 in the <strong>React Playbook</strong> series. We already covered TanStack Query and TanStack Router. Now we need the missing piece: client state that does not belong to the server, but has outgrown <code>useState</code>.</p>
<h2>Query Is Not Your Client Store</h2>
<p>TanStack Query owns server state.</p>
<p>Policies from the API. Customer records. Coverage types. Search results. Anything fetched, cached, invalidated, and synchronized with a backend.</p>
<p>Client state is different:</p>
<ul>
<li>which policy sections are expanded</li>
<li>current unsaved draft</li>
<li>selected rows in a table</li>
<li>multi-step wizard progress</li>
<li>temporary validation warnings</li>
<li>optimistic local operations before submit</li>
<li>undo history</li>
<li>derived review summary</li>
</ul>
<p>The mistake is pushing these into Query because Query is already installed.</p>
<pre><code class="language-ts">queryClient.setQueryData([&#39;policy-builder-draft&#39;], draft);
</code></pre>
<p>This works mechanically. It is also the wrong abstraction. Query cache is built around remote data identity, freshness, invalidation, refetching, and synchronization. A local draft is not stale. It should not refetch. It does not have server freshness semantics.</p>
<p>Use Query for server state.</p>
<p>Use a client-state tool for client state.</p>
<h2>Why Not Just useState?</h2>
<p><code>useState</code> is still the default.</p>
<p>For component-local state, it is perfect:</p>
<pre><code class="language-tsx">const [isOpen, setIsOpen] = useState(false);
const [selectedTab, setSelectedTab] = useState&lt;&#39;details&#39; | &#39;activity&#39;&gt;(&#39;details&#39;);
</code></pre>
<p>Do not install a state library for that.</p>
<p><code>useReducer</code> is good when one component owns a local state machine:</p>
<pre><code class="language-tsx">const [state, dispatch] = useReducer(policyWizardReducer, initialWizardState);
</code></pre>
<p>Still fine.</p>
<p>The trouble starts when state has to be shared, derived, observed, and changed from multiple places.</p>
<p>In the policy builder:</p>
<ul>
<li>The wizard footer needs to know whether any section is dirty.</li>
<li>The review page needs a derived summary.</li>
<li>Section components need local edits.</li>
<li>Autosave needs to react to draft changes.</li>
<li>Permission logic needs to disable actions.</li>
<li>Validation warnings need to attach to sections.</li>
</ul>
<p>You can put all of this in context. But context broadcasts. Every consumer becomes part of the same render conversation. You can split contexts. Then you manage context architecture. You can write selectors. Then you are building a state library slowly, without the debugging tools or mental model.</p>
<p>That is where Reatom enters.</p>
<h2>Why Reatom</h2>
<p>I like Reatom for workflows where state is a graph.</p>
<p>Small atoms. Derived atoms. Actions. Effects. Explicit dependencies. React components subscribe to the pieces they read, not to a giant provider value.</p>
<p>The important thing is not the library name. The important thing is the model:</p>
<blockquote>
<p>Break client state into small atoms, derive what can be derived, and make actions the place where transitions happen.</p>
</blockquote>
<p>That model fits complex frontend workflows better than a single reducer object or a large Zustand store.</p>
<p>Zustand is excellent for simple global stores. I use it happily for small apps: auth-ish UI state, sidebar preferences, feature flags, lightweight cross-component state.</p>
<p>But for CRM-scale workflows with derived state, undo, chained reactions, and local process logic, I prefer a graph. Reatom makes the graph visible.</p>
<h2>A Small Policy Builder Model</h2>
<p>Imagine the policy builder feature:</p>
<pre><code>src/
  features/
    build-policy/
      model/
        policy-builder.atoms.ts
        policy-builder.actions.ts
        policy-builder.selectors.ts
      ui/
        PolicyBuilderPage.tsx
        CoverageSection.tsx
        BeneficiariesSection.tsx
        ReviewPanel.tsx
      index.ts
</code></pre>
<p>The model belongs to the feature because the state exists for this workflow. It is not an entity. It is not shared. It is not a generic app store.</p>
<p>Start with atoms:</p>
<pre><code class="language-ts">// src/features/build-policy/model/policy-builder.atoms.ts
import { atom } from &#39;@reatom/core&#39;;

export interface PolicyDraft {
  holderName: string;
  coverageType: &#39;standard&#39; | &#39;premium&#39;;
  beneficiaries: BeneficiaryDraft[];
}

export const policyDraftAtom = atom&lt;PolicyDraft&gt;(
  {
    holderName: &#39;&#39;,
    coverageType: &#39;standard&#39;,
    beneficiaries: [],
  },
  &#39;policyDraftAtom&#39;,
);

export const activeSectionAtom = atom&lt;PolicySection&gt;(&#39;coverage&#39;, &#39;activeSectionAtom&#39;);

export const dirtySectionsAtom = atom&lt;Set&lt;PolicySection&gt;&gt;(
  new Set(),
  &#39;dirtySectionsAtom&#39;,
);
</code></pre>
<p>Small atoms. Named atoms. No giant <code>policyBuilderStore</code> object with everything inside.</p>
<p>Why?</p>
<p>Because different components care about different parts. The coverage section should not rerender because the beneficiaries list changed. The review panel might care about the full draft, but the active tab indicator only cares about <code>activeSectionAtom</code>.</p>
<h2>Derived State Should Be Derived</h2>
<p>Dirty state often gets stored badly.</p>
<pre><code class="language-ts">const [canSubmit, setCanSubmit] = useState(false);
</code></pre>
<p>Then you remember to update it after every draft change, every validation change, every permission change. Or you forget, and the button lies.</p>
<p>Derived state should be computed from source state:</p>
<pre><code class="language-ts">// src/features/build-policy/model/policy-builder.selectors.ts
import { atom } from &#39;@reatom/core&#39;;
import { dirtySectionsAtom, policyDraftAtom } from &#39;./policy-builder.atoms&#39;;

export const hasUnsavedChangesAtom = atom((ctx) =&gt; {
  return ctx.spy(dirtySectionsAtom).size &gt; 0;
}, &#39;hasUnsavedChangesAtom&#39;);

export const reviewSummaryAtom = atom((ctx) =&gt; {
  const draft = ctx.spy(policyDraftAtom);

  return {
    holder: draft.holderName || &#39;Missing holder name&#39;,
    coverage: draft.coverageType,
    beneficiaryCount: draft.beneficiaries.length,
  };
}, &#39;reviewSummaryAtom&#39;);
</code></pre>
<p>The exact Reatom API can vary by package setup, but the principle is stable: derived atoms observe source atoms and recompute when dependencies change.</p>
<p>No manual synchronization.</p>
<p>No effect that says &quot;when A changes, update B.&quot;</p>
<p>B is a function of A.</p>
<h2>Actions Are Transitions</h2>
<p>I do not want random components mutating atoms however they like.</p>
<p>For meaningful workflow changes, define actions:</p>
<pre><code class="language-ts">// src/features/build-policy/model/policy-builder.actions.ts
import { action } from &#39;@reatom/core&#39;;
import { dirtySectionsAtom, policyDraftAtom } from &#39;./policy-builder.atoms&#39;;

export const updateCoverageType = action(
  (ctx, coverageType: PolicyDraft[&#39;coverageType&#39;]) =&gt; {
    const draft = ctx.get(policyDraftAtom);

    policyDraftAtom(ctx, {
      ...draft,
      coverageType,
    });

    const dirtySections = new Set(ctx.get(dirtySectionsAtom));
    dirtySections.add(&#39;coverage&#39;);
    dirtySectionsAtom(ctx, dirtySections);
  },
  &#39;updateCoverageType&#39;,
);
</code></pre>
<p>The coverage type change is not just a setter. It changes the draft and marks a section dirty. Later it might clear dependent fields. Later it might add a warning. The action is where that transition belongs.</p>
<p>This is the main reason I like action-based state models. They create names for product events:</p>
<ul>
<li><code>updateCoverageType</code></li>
<li><code>removeBeneficiary</code></li>
<li><code>restoreDraft</code></li>
<li><code>markSectionReviewed</code></li>
<li><code>resetBuilder</code></li>
</ul>
<p>Those names are easier to reason about than a trail of <code>setState</code> calls.</p>
<h2>React Components Stay Small</h2>
<p>The component should read state and dispatch intent.</p>
<pre><code class="language-tsx">// src/features/build-policy/ui/CoverageSection.tsx
import { reatomComponent } from &#39;@reatom/npm-react&#39;;
import { policyDraftAtom } from &#39;../model/policy-builder.atoms&#39;;
import { updateCoverageType } from &#39;../model/policy-builder.actions&#39;;

export const CoverageSection = reatomComponent(({ ctx }) =&gt; {
  const draft = ctx.spy(policyDraftAtom);

  return (
    &lt;section&gt;
      &lt;h2&gt;Coverage&lt;/h2&gt;
      &lt;CoverageTypeSelect
        value={draft.coverageType}
        onChange={(coverageType) =&gt; updateCoverageType(ctx, coverageType)}
      /&gt;
    &lt;/section&gt;
  );
}, &#39;CoverageSection&#39;);
</code></pre>
<p>The component does not know how dirty state works. It does not know which dependent values reset. It says: the user changed coverage type.</p>
<p>The model handles the transition.</p>
<p>That separation is the payoff.</p>
<h2>Where Reatom Meets TanStack Query</h2>
<p>Query loads the server state. Reatom owns the local workflow.</p>
<p>When opening an existing policy builder, seed the draft from Query data:</p>
<pre><code class="language-tsx">export function PolicyBuilderRoute({ policyId }: { policyId: string }) {
  const policyQuery = useQuery(policyDetailQueryOptions(policyId));

  if (policyQuery.isPending) return &lt;PolicyBuilderSkeleton /&gt;;
  if (policyQuery.isError) return &lt;PolicyBuilderError onRetry={() =&gt; policyQuery.refetch()} /&gt;;

  return &lt;PolicyBuilderPage initialPolicy={policyQuery.data} /&gt;;
}
</code></pre>
<p>Then initialize the feature model at the boundary:</p>
<pre><code class="language-tsx">export function PolicyBuilderPage({ initialPolicy }: { initialPolicy: PolicyDetail }) {
  const draft = mapPolicyToDraft(initialPolicy);

  return (
    &lt;PolicyBuilderScope initialDraft={draft}&gt;
      &lt;CoverageSection /&gt;
      &lt;BeneficiariesSection /&gt;
      &lt;ReviewPanel /&gt;
    &lt;/PolicyBuilderScope&gt;
  );
}
</code></pre>
<p>The important boundary: server data becomes an initial draft. After that, the draft is client state.</p>
<p>When the user saves:</p>
<pre><code class="language-ts">export const savePolicyDraft = action(async (ctx) =&gt; {
  const draft = ctx.get(policyDraftAtom);

  await updatePolicyDraft(draft);

  dirtySectionsAtom(ctx, new Set());
}, &#39;savePolicyDraft&#39;);
</code></pre>
<p>Then invalidate Query data if needed:</p>
<pre><code class="language-ts">queryClient.invalidateQueries({ queryKey: policyKeys.detail(policyId) });
</code></pre>
<p>Do not keep the Query cache and draft atom synchronized on every keystroke. That turns two clear models into one blurry model.</p>
<h2>Effects Need Discipline</h2>
<p>Autosave is a good example.</p>
<p>It is tempting to put autosave in a component:</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  const id = setTimeout(() =&gt; saveDraft(draft), 1000);
  return () =&gt; clearTimeout(id);
}, [draft]);
</code></pre>
<p>That works until the component unmounts, remounts, gets split, or the draft changes from somewhere else.</p>
<p>For serious workflows, autosave should live near the state model. It reacts to the model, not to a particular component being mounted.</p>
<p>The exact Reatom effect API depends on setup, but the shape is what matters:</p>
<pre><code class="language-ts">export const autosaveDraft = action(async (ctx) =&gt; {
  const draft = ctx.get(policyDraftAtom);
  const hasChanges = ctx.get(hasUnsavedChangesAtom);

  if (!hasChanges) return;

  await savePolicyDraftToServer(draft);
  dirtySectionsAtom(ctx, new Set());
}, &#39;autosaveDraft&#39;);
</code></pre>
<p>Then schedule or trigger it from the feature boundary, not from every field.</p>
<p>Effects should be named. Effects should be cancellable when needed. Effects should not hide in five components.</p>
<h2>Undo and History</h2>
<p>This is where graph-based state starts to feel worth it.</p>
<p>Undo is awkward when state is scattered across components. It is manageable when workflow state has a model.</p>
<pre><code class="language-ts">export const draftHistoryAtom = atom&lt;PolicyDraft[]&gt;([], &#39;draftHistoryAtom&#39;);

export const commitDraftChange = action((ctx, nextDraft: PolicyDraft) =&gt; {
  const currentDraft = ctx.get(policyDraftAtom);
  const history = ctx.get(draftHistoryAtom);

  draftHistoryAtom(ctx, [...history, currentDraft]);
  policyDraftAtom(ctx, nextDraft);
}, &#39;commitDraftChange&#39;);

export const undoDraftChange = action((ctx) =&gt; {
  const history = ctx.get(draftHistoryAtom);
  const previousDraft = history.at(-1);

  if (!previousDraft) return;

  draftHistoryAtom(ctx, history.slice(0, -1));
  policyDraftAtom(ctx, previousDraft);
}, &#39;undoDraftChange&#39;);
</code></pre>
<p>Would I add undo to every form? No.</p>
<p>But when the product needs it, I want the state model to make it boring.</p>
<h2>What Not to Put in Reatom</h2>
<p>Reatom is not a dumping ground.</p>
<p>Do not put this in Reatom by default:</p>
<ul>
<li>fetched server data that belongs in TanStack Query</li>
<li>simple component booleans</li>
<li>URL state that belongs in TanStack Router search params</li>
<li>form field state already owned cleanly by TanStack Form</li>
<li>one-off UI state that never leaves a component</li>
</ul>
<p>The fact that a library can hold state does not mean it should hold all state.</p>
<p>My default split:</p>
<ul>
<li><strong>TanStack Query:</strong> server state</li>
<li><strong>TanStack Router:</strong> URL/navigation state</li>
<li><strong>TanStack Form:</strong> form field state and validation flow</li>
<li><strong>Reatom:</strong> complex client workflows and reactive graphs</li>
<li><strong>useState:</strong> local component details</li>
</ul>
<p>This split keeps each tool honest.</p>
<h2>The Review Checklist</h2>
<p>When I review client state, I ask:</p>
<ul>
<li>Is this state server-owned, URL-owned, form-owned, or client-owned?</li>
<li>Can it stay local with <code>useState</code>?</li>
<li>Is this derived value being stored manually?</li>
<li>Are components changing atoms directly, or dispatching named actions?</li>
<li>Does the feature model live near the feature?</li>
<li>Are effects named and centralized, or hidden in component trees?</li>
<li>Is Query cache being abused as a local store?</li>
<li>Can a new developer see the state graph without opening ten components?</li>
</ul>
<p>The last question matters most. State management is not about reducing lines. It is about making change propagation understandable.</p>
<h2>What Comes Next</h2>
<p>Reatom gives us a way to model complex client workflows. The next article goes one layer deeper: <a href="https://ma-x.im/blog/react-playbook-immutable-data-structures">immutable data structures in React</a>. Not as a purity lecture, but as the practical reason state graphs, memoization, optimistic updates, and undo histories stay predictable.</p>
<hr>
<p>The policy builder eventually stopped feeling like a form and started feeling like a small application. That was the important realization. Treating it as &quot;just component state&quot; made every requirement feel like an exception. Treating it as a workflow model made the requirements fit somewhere.</p>
<p>Not because Reatom was magic.</p>
<p>Because the state graph finally had a shape.</p>
<p>If you&#39;re working on a React feature where every small change triggers three unrelated effects, reach out. The problem may not be your components. It may be that the workflow wants a model of its own.</p>
]]></content:encoded>
      <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Reatom</category>
      <category>State Management</category>
      <category>Architecture</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Stop Quizzing Senior Engineers — Start Talking to Them (TL;DR)</title>
      <link>https://ma-x.im/blog/interviewing-senior-engineers-short</link>
      <guid isPermaLink="true">https://ma-x.im/blog/interviewing-senior-engineers-short</guid>
      <description>The condensed version: why conversational interviews beat trivia quizzes for senior roles, with practical examples and actionable advice.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/interviewing-senior-engineers-short/cover.png" alt="Stop Quizzing Senior Engineers — Start Talking to Them (TL;DR)" /></p>
<p>I recently wrote a <a href="https://ma-x.im/blog/interviewing-senior-engineers">detailed article</a> on this topic. It came out long because I wanted to cover every nuance — the full story, detailed interview examples, the AI angle, common mistakes, and practical formats. But I realize not everyone has 15 minutes to spare, so here is the short version covering only the essentials.</p>
<h2>The Problem</h2>
<p>Experienced engineers fail interviews. Not because they lack skill — because the interview tests the wrong thing.</p>
<p>I have over a decade of production frontend experience. TypeScript, React, enterprise products, design systems, team leadership. And I have failed interviews because I could not recall the exact behavior of <code>Promise.allSettled</code> under pressure or write a tree traversal algorithm from memory. These are things I almost never do by hand in real work.</p>
<p>The trivia-based interview format creates false negatives (rejecting experienced builders who do not drill interview prep) and false positives (passing candidates who memorized answers but cannot make architecture decisions). It tests recall under stress — a scenario that never occurs in actual engineering work.</p>
<h2>What Works Instead</h2>
<p>Talk to the candidate like a colleague. Ask about real scenarios and follow up on every answer.</p>
<p>The best interview I ever had was with a guy named John at a large Canadian company in the automotive industry. Two hours. No algorithms, no trivia. Just questions like:</p>
<ul>
<li>&quot;You inherit a React app with 200+ components and no design system. What is your first week like?&quot;</li>
<li>&quot;Two teams depend on the same component library but ship on different schedules. How do you handle versioning?&quot;</li>
<li>&quot;A junior on your team writes overly abstract code. How do you handle that conversation?&quot;</li>
</ul>
<p>Every answer led to a follow-up: &quot;What if the team pushes back?&quot; &quot;What if the deadline is two weeks?&quot; He was mapping how I think, not checking what I have memorized.</p>
<p>I got the job and stayed for three years. No whiteboard session would have predicted that outcome better.</p>
<h2>How Simple Questions Reveal Deep Expertise</h2>
<p>You do not need complex scenarios. Start simple and follow the thread.</p>
<p><strong>Example: &quot;Your team needs to support dark mode across the product. How do you approach it?&quot;</strong></p>
<p>A junior talks about CSS variables. A senior asks: &quot;How big is the product? Is there a design system? Does design have dark mode tokens ready? Is this a gradual rollout?&quot; Then follow up: &quot;Design only covers 40% of components — the rest is on you.&quot; Then: &quot;A senior dev wants to refactor all CSS first. What do you do?&quot; Then: &quot;Product wants to A/B test with 10% of users.&quot;</p>
<p>One question. Fifteen minutes. You see problem decomposition, ambiguity handling, leadership instincts, and technical depth. No algorithm required.</p>
<p><strong>Example: &quot;Users say the app feels slow. No specific page, just... slow. What do you do?&quot;</strong></p>
<p>A senior starts with: &quot;Before I touch code — what does slow mean to users? Initial load? Navigation? Interactions? Do we have metrics? Is it all users or specific segments?&quot; Then you push: &quot;Mainly mobile users on slow networks.&quot; Then: &quot;VP wants a fix in two weeks for a board meeting.&quot; Then: &quot;Backend says APIs are fine, it is purely frontend.&quot;</p>
<p>Two questions. Thirty minutes total. You know how this person operates in the real world.</p>
<h2>Ask About AI</h2>
<p>In 2026, skipping AI in a senior interview means missing critical signal. Some seniors write ten lines by hand per week and orchestrate AI agents for the rest. Others barely use AI. Both can be effective.</p>
<p>The issue: candidates will not volunteer their AI usage honestly unless you make it safe. There is still stigma.</p>
<p>Ask openly:</p>
<ul>
<li>&quot;Walk me through how you built the last feature you shipped — tools, process, everything.&quot;</li>
<li>&quot;When you use AI-generated code, what is your review process?&quot;</li>
<li>&quot;A developer submits a PR that is entirely AI-generated. They understand it, but did not write it by hand. How do you feel as a reviewer?&quot;</li>
</ul>
<p>You are evaluating judgment about tools and quality — not whether they use AI.</p>
<h2>Where This Breaks</h2>
<p><strong>Requires skilled interviewers.</strong> You cannot run this from an answer sheet. The interviewer needs domain depth and the ability to improvise follow-ups.</p>
<p><strong>Consistency is hard.</strong> Different interviewers, different questions, different evaluations. Shared rubrics and calibration sessions are essential.</p>
<p><strong>Charisma bias.</strong> Open conversations favor articulate, sociable candidates. Evaluate substance of answers, not delivery style.</p>
<p><strong>Takes more time.</strong> You need 60–90 minutes. This does not work for high-volume initial screening — use it in final rounds.</p>
<h2>Common Mistakes</h2>
<ul>
<li>Having a &quot;correct&quot; answer in your head. You are evaluating reasoning, not checking solutions.</li>
<li>Being too casual. &quot;Just a chat&quot; produces no signal. You need evaluation criteria.</li>
<li>Not following up. The first answer is surface level. Push with &quot;why?&quot;, &quot;what if?&quot;, &quot;what could go wrong?&quot;</li>
<li>Not calibrating across interviewers. Without shared rubrics, you get incomparable evaluations.</li>
<li>Treating AI usage as a red flag. The right follow-up to &quot;I use AI for 80% of code&quot; is &quot;how do you ensure quality?&quot; — not a concerned look.</li>
</ul>
<h2>Quick Recommendations</h2>
<p><strong>Interviewers:</strong> Build 5–8 scenario questions from real team challenges. Write a rubric with clear dimensions (problem decomposition, trade-off awareness, communication, technical depth, collaboration, leadership). Run calibration sessions quarterly. Ask about AI openly.</p>
<p><strong>Candidates:</strong> When you do not remember an API detail — say so and explain the concept. Prepare stories about real projects, not rehearsed definitions. Be honest about your AI workflow. Ask your own questions aggressively — they signal seniority more than your answers do. If the interview feels like an exam, that is a signal about the culture.</p>
<h2>Summary</h2>
<p>Senior interviews should test judgment, not recall. Simple scenario questions with deep follow-ups reveal more about an engineer&#39;s real capability than any algorithm or trivia quiz. It takes better interviewers and more time, but you hire people who can actually do the job. In an era where AI handles routine code generation, the quality of decisions is what makes a senior engineer valuable. Test for that.</p>
<p>For the full deep-dive with complete interview formats and extended examples, read the <a href="https://ma-x.im/blog/interviewing-senior-engineers">long version</a>.</p>
<hr>
<p><em>If you have thoughts on this or want to share your own interview experience — <a href="https://ma-x.im/contact">reach out</a>.</em></p>
]]></content:encoded>
      <pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate>
      <category>Interviews</category>
      <category>Engineering Culture</category>
      <category>Leadership</category>
      <category>Career</category>
      <category>TL;DR</category>
    </item>
    <item>
      <title>Stop Quizzing Senior Engineers — Start Talking to Them</title>
      <link>https://ma-x.im/blog/interviewing-senior-engineers</link>
      <guid isPermaLink="true">https://ma-x.im/blog/interviewing-senior-engineers</guid>
      <description>Why conversational interviews produce better hiring signal than algorithmic puzzles and API trivia — and how one interview with a guy named John changed how I think about hiring.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/interviewing-senior-engineers/cover.png" alt="Stop Quizzing Senior Engineers — Start Talking to Them" /></p>
<p>I have failed interviews. Multiple times. Not because I did not know my craft — I have been shipping production frontend for well over a decade. I failed because I could not recite the exact behavior of <code>Promise.allSettled</code> under pressure, or because I blanked on a tree traversal algorithm I had not hand-written since university. Meanwhile, the things I actually do every day — making architecture decisions, untangling legacy code, mentoring developers, designing systems that survive contact with real users — none of that was being tested.</p>
<p>This article is about what a good senior interview looks like. I know because I experienced one, and it changed everything.</p>
<h2>Context: The Interview Problem Nobody Talks About</h2>
<p>There is an uncomfortable truth in our industry. Many experienced engineers — people who have built and maintained complex production systems for years — routinely fail technical interviews.</p>
<p>Not because they are bad engineers. Because the interview tests a different skill than the job requires.</p>
<p>I work primarily with TypeScript and React. Promises? I use them every day — through async/await, through React Query, through framework abstractions. But when someone puts me in front of a whiteboard and says &quot;chain three promises, handle partial failures, and explain the microtask queue,&quot; I stumble. Not because I do not understand the concept. Because I do not carry that specific API surface in my active memory. I have not needed to.</p>
<p>Tree traversals and graph algorithms? Honestly — I do not enjoy them and I rarely encounter them in my daily work. When I need something like that, I reach for a library or write a straightforward recursive approach. I do not sit and think about BFS vs DFS complexity tradeoffs while building a dashboard for an enterprise client.</p>
<p>Logical puzzles? Same story. I can solve them given time and a calm environment. Under interview pressure, with someone watching me and a timer ticking? I freeze. And I know I am not alone.</p>
<p>Here is what bothers me most: I have seen engineers who drill LeetCode for months pass these interviews with flying colors, then struggle to make a single meaningful architecture decision in their first sprint. The interview selected for the wrong thing. It tested preparation discipline, not engineering capability.</p>
<p>I am not saying algorithmic knowledge is useless. I am saying that for a senior role, it should not be the primary filter. And yet — at most companies — it still is.</p>
<h2>The Interview with John</h2>
<p>Let me tell you about the best technical interview I have ever experienced.</p>
<p>It was at a large Canadian company. Well-known products — automotive industry. The kind of place where the frontend work is complex, the user base is massive, and the decisions you make on Monday are still affecting the product two years later.</p>
<p>My interviewer was John. I remember his name because what happened in that interview stuck with me permanently. I still feel a kind of gratitude toward him years later — not because he went easy on me, but because he showed me what a respectful, effective technical interview looks like.</p>
<p>We were scheduled for ninety minutes. We went over two hours. I did not notice the time passing. Not once did I feel like I was being tested. It felt like I was having a deep technical conversation with a colleague I respected.</p>
<p>John did not ask me what a closure is. He did not ask me to reverse a linked list. He did not ask me to explain the event loop.</p>
<p>Instead, he asked things like:</p>
<ul>
<li>&quot;You join a team and inherit a React app with 200+ components and no design system. What is your first week like?&quot;</li>
<li>&quot;The product team wants to support three different brands from the same codebase. How would you approach the frontend?&quot;</li>
<li>&quot;You notice the CI pipeline takes 40 minutes and developers are complaining. Where do you start?&quot;</li>
<li>&quot;Two teams depend on the same component library but ship on different schedules. How would you handle versioning?&quot;</li>
<li>&quot;A junior on your team writes overly abstract code — creates a generic wrapper for everything. How do you handle that conversation?&quot;</li>
</ul>
<p>Every answer I gave led somewhere. John would nod and then say, &quot;Okay, but what if the team pushes back on that approach?&quot; or &quot;What if the deadline is in two weeks?&quot; or &quot;What happens when the third team joins and they use a different state management solution?&quot; He was not looking for a specific answer. He was mapping how I think.</p>
<p>I got the job. I stayed for about three years. The team was strong, the work was meaningful, and as far as I could tell, the company did not regret the hire. That interview — with no algorithms, no trivia, no trick questions — predicted my actual job performance far better than any whiteboard session ever could.</p>
<p>That experience left me with a conviction: you learn more about a senior engineer in thirty minutes of genuine conversation than in two hours of trivia.</p>
<h2>Why Trivia-Based Senior Interviews Are Fundamentally Broken</h2>
<p>Let me be specific about the failure modes.</p>
<p><strong>The wrong skill is being tested.</strong> When you ask a senior engineer to write a <code>debounce</code> function from memory, you are testing recall. But a senior&#39;s actual value lies in knowing <em>when</em> to debounce, <em>where</em> it matters for user experience, and <em>whether</em> the framework already provides a better abstraction. The API signature is a Google search away. The judgment is not.</p>
<p><strong>False negatives destroy your pipeline.</strong> The most experienced candidates — the ones who have been building real systems instead of doing interview prep — are the ones most likely to fail trivia questions. They have been too busy shipping code to memorize it. You are systematically filtering out the people you want most.</p>
<p><strong>False positives are expensive.</strong> A candidate who spent three months on an interview prep platform will ace your quiz. They might know every closure edge case, every prototype chain gotcha, every <code>this</code> binding trap. But put them in front of a real architecture decision — &quot;should we build a micro-frontend or keep the monolith?&quot; — and they might have nothing to say. That mismatch costs you months of onboarding and potentially a bad hire.</p>
<p><strong>Stress is not signal.</strong> There is a pervasive belief that &quot;if they can perform under pressure, they will be good in production.&quot; This is wrong. Interview stress and production pressure are fundamentally different experiences. In production, you have context, tools, colleagues, and time to think. In an interview, you have a stranger staring at you while you try to remember if <code>Array.prototype.flat</code> takes a depth argument. These are not comparable situations. You are measuring anxiety tolerance, not engineering skill.</p>
<p><strong>It does not match how we actually work.</strong> In my daily work, I look things up constantly. I read documentation. I discuss approaches in Slack. I use AI to scaffold boilerplate. I do code reviews where I catch things I would not have remembered in an interview. The isolated, no-tools, no-references interview environment tests a scenario that literally never occurs in the actual job.</p>
<h2>The Conversational Interview: What It Looks Like in Practice</h2>
<p>The alternative is not &quot;just chatting.&quot; A conversational interview has structure, evaluation criteria, and clear goals. But it evaluates what matters: how the candidate thinks about problems, makes decisions, and handles complexity.</p>
<p>Let me walk through two complete examples. These are realistic — the kind of conversations you could have with a senior frontend candidate next week.</p>
<h3>Example 1: The Innocent Question That Reveals Everything</h3>
<p>Imagine you start the interview with this:</p>
<p><strong>&quot;Hey, so — your team has just been told that you need to support dark mode across the entire product. How do you approach this?&quot;</strong></p>
<p>This sounds almost too simple. Dark mode. Every junior knows about <code>prefers-color-scheme</code>. But this question is a depth charge. Watch what happens when you follow the thread.</p>
<p>A junior will talk about CSS variables and a toggle button. A mid-level will mention design tokens, maybe theming with styled-components or Tailwind.</p>
<p>A senior? A senior will start asking questions back:</p>
<ul>
<li>&quot;How big is the product? How many pages, how many components?&quot;</li>
<li>&quot;Is there a design system, or are styles scattered?&quot;</li>
<li>&quot;Does the design team have dark mode designs ready, or are we deriving them?&quot;</li>
<li>&quot;What is the CSS architecture — utility-first, CSS modules, CSS-in-JS?&quot;</li>
<li>&quot;Are there third-party components that do not support theming?&quot;</li>
<li>&quot;Is this a gradual rollout or a big-bang release?&quot;</li>
</ul>
<p>Now you are in a real conversation. Follow up with:</p>
<ul>
<li><p><strong>&quot;The design team says they will provide dark mode tokens, but only for the core components. The rest — about 60% of the UI — you have to figure out.&quot;</strong> This tests whether they can handle ambiguity and make pragmatic decisions about imperfect situations.</p>
</li>
<li><p><strong>&quot;One of the senior developers on the team insists on doing a full CSS refactor before implementing dark mode. He says the current styles are too messy. What do you do?&quot;</strong> This is no longer a technical question — it is a leadership and collaboration question. How do they handle disagreement? Do they see the tradeoff between ideal and practical?</p>
</li>
<li><p><strong>&quot;Product says they want to A/B test dark mode with 10% of users first.&quot;</strong> Now you are in feature flags, runtime theming, and state management territory. Do they think about persistence? Server-side rendering? Flash of unstyled content?</p>
</li>
</ul>
<p>One question. Fifteen minutes. You have learned more about this person&#39;s seniority than any algorithm challenge could reveal. You have seen how they decompose problems, how they handle incomplete information, how they think about people and process alongside code.</p>
<h3>Example 2: The Deceptively Simple Scenario</h3>
<p>Here is another one:</p>
<p><strong>&quot;A product manager comes to you and says: users are complaining that the app feels slow. No specific page, just... slow. What do you do?&quot;</strong></p>
<p>Again — sounds easy. But a senior engineer&#39;s answer to this question reveals an enormous amount about their experience.</p>
<p>The junior answer: &quot;I would use Lighthouse and fix the scores.&quot;</p>
<p>The mid-level answer: &quot;I would profile the app, check bundle size, look at network requests.&quot;</p>
<p>The senior answer starts with: &quot;Before I touch any code, I need to understand what &#39;slow&#39; means to the users.&quot;</p>
<ul>
<li>&quot;Is it initial load time? Navigation between pages? Interaction responsiveness? All of the above?&quot;</li>
<li>&quot;Do we have any metrics — Core Web Vitals, custom performance tracking, session recordings?&quot;</li>
<li>&quot;Is it slow for all users or specific segments — mobile, specific regions, specific browsers?&quot;</li>
<li>&quot;When did it start feeling slow? Was there a recent deployment that correlates?&quot;</li>
</ul>
<p>Follow up:</p>
<ul>
<li><p><strong>&quot;Turns out it is mainly mobile users in regions with slower networks. Desktop users are fine.&quot;</strong> This pushes toward real-world constraints — code splitting, server-side rendering, image optimization, CDN strategy. Do they think about the network layer, or only about JavaScript?</p>
</li>
<li><p><strong>&quot;The VP of Engineering wants a fix in two weeks because there is a board meeting.&quot;</strong> Now you are testing prioritization under pressure. Do they panic and promise everything? Do they push back with data? Do they suggest a phased approach — quick wins first, structural changes later?</p>
</li>
<li><p><strong>&quot;The backend team says the APIs are fine and the problem is purely on the frontend.&quot;</strong> How do they handle cross-team accountability? Do they accept that at face value, or do they know how to prove or disprove it with data?</p>
</li>
</ul>
<p>Two questions. Two conversations. Thirty minutes total. You now have a detailed picture of how this person operates in the real world. You know their technical depth, their communication style, their leadership instincts, and their ability to work with ambiguity. No whiteboard required.</p>
<h3>What Makes These Questions Work</h3>
<p>Notice what both examples have in common:</p>
<ul>
<li><strong>The initial question is approachable.</strong> It does not trigger &quot;I need to remember the right answer&quot; anxiety. It triggers &quot;let me think about this problem.&quot;</li>
<li><strong>Depth comes from follow-ups, not from the question itself.</strong> The interviewer&#39;s skill matters. You need to know where to push.</li>
<li><strong>There is no single correct answer.</strong> Multiple good approaches exist. What you evaluate is reasoning, awareness of tradeoffs, and the ability to adapt when the situation changes.</li>
<li><strong>They test real-world skills.</strong> Ambiguity handling, stakeholder management, prioritization, team dynamics — these are what senior engineers actually deal with every day.</li>
</ul>
<h2>The AI Question Every Senior Interview Needs in 2026</h2>
<p>Let me say something that might be controversial: if you are interviewing a senior engineer in 2026 and you do not ask about AI, you are missing a critical data point.</p>
<p>Here is the reality. I know engineers — good ones, experienced ones — who write maybe ten lines of code by hand per week. The rest is orchestrating AI agents. They prompt, they review, they refine, they iterate. And they are productive. They ship features, maintain quality, and make good architectural decisions. They are still seniors — arguably more effective seniors than before.</p>
<p>I also know engineers who barely use AI. They write everything by hand, they are meticulous, and they produce excellent work. Also valid.</p>
<p>The interesting question is not &quot;do you use AI?&quot; It is everything that comes after.</p>
<p>But here is the problem: candidates will not talk about this honestly unless you make it safe. There is still a stigma. Engineers worry that admitting heavy AI usage will make them look lazy or less skilled. So they downplay it. And you miss the real picture.</p>
<p>How to ask:</p>
<ul>
<li><strong>&quot;Walk me through how you built the last feature you shipped. Tools, process, everything.&quot;</strong> This is open-ended enough that they can naturally mention AI without feeling judged.</li>
<li><strong>&quot;When you use AI-generated code, what is your review process? What do you look for?&quot;</strong> This tells you whether they are using AI thoughtfully or blindly.</li>
<li><strong>&quot;Have you seen AI-generated code cause a problem in production? What happened?&quot;</strong> A senior who uses AI regularly will have a story. And how they handled it tells you about their quality standards.</li>
<li><strong>&quot;A developer on your team submits a PR that is entirely AI-generated. They understand it, but they did not write any of it by hand. How do you feel about that as a reviewer?&quot;</strong> This tests their philosophy about code ownership, maintainability, and team standards.</li>
</ul>
<p>What you are really evaluating is judgment. Does this person use AI as a power tool while maintaining engineering responsibility? Or are they outsourcing their thinking? There is a big difference, and a conversational interview is the only format that can surface it.</p>
<h2>Where Conversational Interviews Break Down</h2>
<p>I am not going to pretend this approach is perfect. It has real limitations.</p>
<p><strong>It requires skilled interviewers.</strong> Running a conversational interview well is hard. You need someone who knows the domain deeply enough to improvise follow-ups, challenge weak answers, and recognize strong ones even when they differ from what they expected. A trivia quiz can be run by anyone with an answer sheet. A conversational interview needs a senior engineer who is also a good communicator. Not every team has that.</p>
<p><strong>Consistency is genuinely difficult.</strong> When different interviewers ask different questions in different ways, comparing candidates becomes harder. Two candidates might both be strong, but the scores look different because one interviewer went deep on architecture while another focused on team dynamics. You need a shared rubric with explicit evaluation dimensions to mitigate this.</p>
<p><strong>It takes more time.</strong> A meaningful conversational interview needs 60 to 90 minutes. You cannot rush it to 30. For companies doing high-volume hiring, that is a real constraint.</p>
<p><strong>Charisma bias is a real risk.</strong> Open-ended conversations naturally favor people who are articulate, confident, and personable. Brilliant engineers who are introverted, or who communicate better in writing than in speech, or who think slowly and carefully, can get undervalued. Your rubric needs to account for this — evaluate the substance of answers, not the delivery.</p>
<p><strong>Some candidates genuinely prefer structured tests.</strong> Not everyone thrives in ambiguity. Some great engineers feel more comfortable with a clear problem, a clear solution, and a clear evaluation. Dismissing their preference entirely is its own form of bias.</p>
<h2>Common Mistakes When Running Conversational Interviews</h2>
<p><strong>Asking scenario questions but having a &quot;correct&quot; answer in your head.</strong> If you already decided that the right approach to the dark mode question is &quot;use CSS custom properties with a ThemeProvider,&quot; you are just running trivia with extra steps. The goal is to evaluate reasoning, not to check if they chose the same solution you would.</p>
<p><strong>Being too casual.</strong> &quot;Let&#39;s just chat&quot; produces zero usable signal. You need a plan: which scenarios, which follow-ups, what evaluation criteria. The conversation should feel informal. The evaluation should be rigorous.</p>
<p><strong>Not going deep enough.</strong> The first answer to any question is the surface layer. The signal is in the second and third follow-ups. &quot;Why that approach?&quot; &quot;What could go wrong?&quot; &quot;What would you do differently with more time?&quot; If you accept the first answer and move on, you are wasting the format.</p>
<p><strong>Failing to calibrate.</strong> Three interviewers running three different conversational interviews without shared criteria will produce three incomparable evaluations and an argument in the debrief meeting. Calibration sessions — where interviewers evaluate the same recorded interview and compare notes — are not optional.</p>
<p><strong>Testing outside the candidate&#39;s experience.</strong> If someone has spent their career in React and you ask them to design a distributed backend system, you will get a weak answer that tells you nothing. Tailor your scenarios to the role and the candidate&#39;s actual background.</p>
<p><strong>Treating AI usage as a negative signal.</strong> This one deserves its own bullet point because it is still happening everywhere. If a candidate openly says &quot;I use AI for 80% of my initial code generation,&quot; the correct follow-up is &quot;how do you ensure quality and maintainability?&quot; Not a raised eyebrow.</p>
<h2>When NOT To Use Conversational Interviews</h2>
<p>This format works best for senior, staff, and principal-level roles where judgment, systems thinking, and leadership matter more than implementation speed.</p>
<p>For <strong>junior roles</strong>, you need to verify fundamental skills. A coding exercise — even a simple one — is reasonable. You need to know they can write a function, debug an error, use basic data structures. A conversational interview would not produce enough signal at this level because junior candidates might not have enough experience to discuss.</p>
<p>For <strong>mid-level roles</strong>, a hybrid works well. A short practical task — maybe a small feature implementation or a debugging exercise — followed by a conversational segment about their approach and decisions.</p>
<p>For <strong>high-volume pipelines</strong> processing hundreds of applicants per week, conversational interviews do not work for initial screening. Use them in final rounds where signal quality matters most and the candidate pool is already filtered.</p>
<p>And if you honestly cannot train your interviewers to run these well — if they will default to &quot;just chatting&quot; without structure or evaluation criteria — then a structured technical assessment with a clear rubric will give you more consistent, if less insightful, results. A mediocre conversational interview is worse than a well-run structured one.</p>
<h2>How I Apply This</h2>
<p>When I interview senior candidates now, the first thing I say is: &quot;This is not a test. I want to understand how you think about problems.&quot; You can visibly see people relax. The dynamic shifts from &quot;exam&quot; to &quot;conversation.&quot; And that shift is where the good signal lives.</p>
<p>I pick two or three scenarios from projects I have actually worked on — problems where I already know the trade-offs, the failure modes, the &quot;it depends&quot; factors. This lets me push on follow-ups authentically instead of working from a script.</p>
<p>I pay close attention to how candidates handle what they do not know. Do they acknowledge the gap and think through it? Do they ask clarifying questions? Do they try to bluff? That single behavioral signal — comfort with uncertainty — correlates more strongly with real-world senior performance than any technical test I have seen.</p>
<p>I always ask about AI. Always. The answer tells me whether they are adapting to how our craft is evolving or clinging to how it used to be. Both types of engineers can be effective, but I want to know which one I am hiring.</p>
<p>The best senior hires I have been part of all came through conversational interviews. The worst mismatch I ever witnessed was someone who aced every technical quiz but could not have a productive discussion about trade-offs in their first sprint. They knew all the answers. They just could not apply them to real problems.</p>
<h2>Practical Recommendations</h2>
<p><strong>If you are an interviewer or hiring manager:</strong></p>
<ul>
<li>Build a library of 5–8 scenario questions drawn from real challenges your team has faced. Rotate them across interviews.</li>
<li>Write a rubric with clear dimensions: problem decomposition, trade-off awareness, communication clarity, technical depth, collaboration instincts, leadership signal. Score each dimension independently.</li>
<li>Run calibration sessions at least quarterly. Have two or three interviewers evaluate the same mock candidate (or a recorded session) and compare scores. Discuss disagreements.</li>
<li>Let interviews run long if the conversation is productive. Cutting off a great discussion at exactly 60 minutes because the calendar says so is counterproductive.</li>
<li>Ask about AI openly. Normalize it. The candidates who are honest about their AI workflow are giving you more useful information than those who claim they write everything by hand.</li>
</ul>
<p><strong>If you are a candidate:</strong></p>
<ul>
<li>When you do not remember an exact API or detail — say so, explain the concept, and describe how you would find it. This is what you would do at work. A good interviewer will not hold it against you. A bad one will, and that tells you something about the company.</li>
<li>Prepare stories, not answers. Think about real projects: problems you encountered, decisions you made, trade-offs you accepted, things you would do differently. Stories are memorable and specific. Rehearsed definitions are not.</li>
<li>Be honest about your AI workflow. If you use Copilot, Cursor, Claude, or any other tool heavily — own it. Explain how you review, validate, and maintain what gets generated. Hiding your actual workflow means the company is evaluating a version of you that does not exist.</li>
<li>Pay attention to how you are being interviewed. If it feels like a university exam — question after question, right or wrong, no discussion — that is a signal about the engineering culture. Use that information when you decide whether to accept or decline.</li>
<li>Ask your own questions aggressively. What is the team&#39;s architecture like? How are decisions made? What was the last big technical bet that did not work out? Your questions reveal your seniority more than your answers do.</li>
</ul>
<h2>Summary</h2>
<p>The best technical interview I ever had involved zero algorithms, zero API trivia, and zero whiteboard coding. It was a two-hour conversation with a guy named John at a large Canadian automotive company, where he asked me real questions about real problems and followed every answer with &quot;what if?&quot; I learned more about how interviews should work from that single experience than from dozens of standard ones on both sides of the table.</p>
<p>Senior interviews should test judgment. They should surface how a candidate thinks about ambiguity, trade-offs, team dynamics, and the realities of production systems. Conversational formats — scenario-based discussions, simple-sounding questions with deep follow-ups, collaborative problem-solving — do this better than any quiz.</p>
<p>They require better interviewers. They require shared rubrics and calibration. They take more time. But the trade-off is clear: you hire people who can actually do the job, not just people who can pass the test.</p>
<p>In an era where AI writes a significant chunk of our code, the thing that makes a senior engineer valuable is not what they can recite from memory. It is the quality of the decisions they make. Test for that.</p>
<hr>
<p><em>If you have thoughts on how senior interviews should work — or horror stories from the other side — <a href="https://ma-x.im/contact">I&#39;d love to hear them</a>.</em></p>
]]></content:encoded>
      <pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate>
      <category>Interviews</category>
      <category>Engineering Culture</category>
      <category>Leadership</category>
      <category>Career</category>
      <category>Long Read</category>
    </item>
    <item>
      <title>Routing with TanStack Router: Treat the URL Like Application State</title>
      <link>https://ma-x.im/blog/react-playbook-tanstack-router</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-tanstack-router</guid>
      <description>Routing is not just rendering a page for a path. In serious React apps, the URL carries product state: filters, tabs, pagination, selected records, redirects, and preload decisions. TanStack Router makes that state typed instead of hopeful.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-tanstack-router/cover.png" alt="Routing with TanStack Router: Treat the URL Like Application State" /></p>
<p>The policy list bug started as a support ticket.</p>
<p>An agent filtered policies by status, page, and renewal date. She copied the URL and sent it to another agent. The second agent opened it and saw a different list.</p>
<p>Same page. Same product. Different state.</p>
<p>The filters were not in the URL. The page number lived in local component state. The selected tab lived in a store. The search box was debounced into a fetch call, but never synchronized back to navigation. The route looked shareable. It wasn&#39;t.</p>
<p>Honestly, that bug changed how I think about routing.</p>
<p>Routing is not just &quot;which component renders for this path.&quot; That is the small version. In product software, routing is about preserving intent. What screen is the user on? Which record? Which filters? Which tab? Which page? Can they reload and get the same view? Can they send the link to someone else and trust it?</p>
<p>I&#39;ve seen teams treat the URL as decoration. Then they spend months rebuilding browser behavior manually.</p>
<p>TanStack Router gives React apps a better option: typed routes, typed params, typed search, loaders, redirects, preloading, and integration with TanStack Query. The point is not fancy routing. The point is making navigation state explicit.</p>
<blockquote>
<p>A URL is not a string you push into history. It is part of your application&#39;s state model.</p>
</blockquote>
<p>This is article #6 in the <strong>React Playbook</strong> series. We already covered the stack, TypeScript patterns, code structure, error handling, and TanStack Query. Now we connect those pieces through routing.</p>
<h2>Why TanStack Router</h2>
<p>React Router is familiar. It works. I used it for years.</p>
<p>The reason I reach for TanStack Router in new TypeScript-heavy apps is not that React Router is bad. It is that TanStack Router makes more route mistakes impossible.</p>
<p>With TanStack Router, routes are registered into a typed route tree. That means:</p>
<ul>
<li><code>&lt;Link to=&quot;/policies/$policyId&quot;&gt;</code> knows which params are required.</li>
<li><code>useParams({ from: &#39;/policies/$policyId&#39; })</code> returns the right param type.</li>
<li><code>useSearch({ from: &#39;/policies&#39; })</code> returns validated search state.</li>
<li><code>navigate({ to, params, search })</code> is checked against the actual route.</li>
<li>Loaders can use route context and params safely.</li>
</ul>
<p>The difference is subtle until a route changes.</p>
<p>Rename <code>$policyId</code> to <code>$id</code>, and TypeScript tells you everywhere the old param is still used. Remove a search param, and navigation calls fail to compile. That is the kind of friction I want.</p>
<p>Not because I enjoy types for their own sake.</p>
<p>Because broken links and stale URL assumptions are production bugs.</p>
<h2>File-Based or Code-Based Routes?</h2>
<p>TanStack Router supports both file-based and code-based routing.</p>
<p>For large teams, I like file-based routing. It creates a predictable structure, works well with route code splitting, and keeps route files close to pages.</p>
<p>For teaching and small early projects, code-based routing is easier to see in one place.</p>
<p>This article uses a file-based shape because it is what I would use once the app grows:</p>
<pre><code>src/
  routes/
    __root.tsx
    index.tsx
    policies/
      index.tsx
      $policyId.tsx
      $policyId.edit.tsx
  routeTree.gen.ts
</code></pre>
<p>In an FSD-style project, I still keep page UI in <code>pages/</code> and let route files wire it:</p>
<pre><code>src/
  routes/
    policies/
      index.tsx
      $policyId.tsx
  pages/
    policy-list/
      PolicyListPage.tsx
    policy-detail/
      PolicyDetailPage.tsx
</code></pre>
<p>Route files are integration points. They connect URL shape, loader behavior, search validation, and page components. They should not become business logic containers.</p>
<h2>The Root Route</h2>
<p>The root route is where app-level layout and route context begin.</p>
<pre><code class="language-tsx">// src/routes/__root.tsx
import { Outlet, createRootRouteWithContext } from &#39;@tanstack/react-router&#39;;
import type { QueryClient } from &#39;@tanstack/react-query&#39;;
import { RootLayout } from &#39;@/app/RootLayout&#39;;

interface RouterContext {
  queryClient: QueryClient;
  auth: AuthSession | null;
}

export const Route = createRootRouteWithContext&lt;RouterContext&gt;()({
  component: RootRoute,
});

function RootRoute() {
  return (
    &lt;RootLayout&gt;
      &lt;Outlet /&gt;
    &lt;/RootLayout&gt;
  );
}
</code></pre>
<p>That context matters. Later, route loaders can access <code>queryClient</code> without importing a singleton. Auth checks can use the current session. Tests can provide a different context.</p>
<p>Then create the router:</p>
<pre><code class="language-ts">// src/app/router.ts
import { createRouter } from &#39;@tanstack/react-router&#39;;
import { routeTree } from &#39;@/routeTree.gen&#39;;
import { queryClient } from &#39;./query-client&#39;;
import { authSession } from &#39;./auth-session&#39;;

export const router = createRouter({
  routeTree,
  context: {
    queryClient,
    auth: authSession,
  },
});

declare module &#39;@tanstack/react-router&#39; {
  interface Register {
    router: typeof router;
  }
}
</code></pre>
<p>The <code>Register</code> declaration is the bridge that gives the rest of the app type-safe navigation. Skip it and you lose much of the point.</p>
<h2>Route Params Should Be Boring</h2>
<p>A policy detail route:</p>
<pre><code class="language-tsx">// src/routes/policies/$policyId.tsx
import { createFileRoute } from &#39;@tanstack/react-router&#39;;
import { PolicyDetailPage } from &#39;@/pages/policy-detail&#39;;

export const Route = createFileRoute(&#39;/policies/$policyId&#39;)({
  component: PolicyDetailRoute,
});

function PolicyDetailRoute() {
  const { policyId } = Route.useParams();

  return &lt;PolicyDetailPage policyId={policyId} /&gt;;
}
</code></pre>
<p><code>policyId</code> is typed from the route path. No manual generic. No <code>as string</code>. No &quot;I hope this param exists.&quot;</p>
<p>Navigation is typed too:</p>
<pre><code class="language-tsx">import { Link } from &#39;@tanstack/react-router&#39;;

export function PolicyRow({ policy }: { policy: Policy }) {
  return (
    &lt;Link to=&quot;/policies/$policyId&quot; params={{ policyId: policy.id }}&gt;
      {policy.policyNumber}
    &lt;/Link&gt;
  );
}
</code></pre>
<p>If you forget <code>policyId), TypeScript complains. If you pass </code>id<code>instead of</code>policyId`, TypeScript complains. That is boring in the best possible way.</p>
<h2>Search Params Are Product State</h2>
<p>The policy list route is where TanStack Router starts to feel different.</p>
<p>Filters belong in the URL:</p>
<pre><code>/policies?status=active&amp;page=2&amp;query=miller
</code></pre>
<p>Not because URLs are pretty. Because users reload pages, bookmark views, share links, and go back in browser history.</p>
<p>Define the search shape:</p>
<pre><code class="language-tsx">// src/routes/policies/index.tsx
import { createFileRoute } from &#39;@tanstack/react-router&#39;;
import { z } from &#39;zod&#39;;
import { PolicyListPage } from &#39;@/pages/policy-list&#39;;

const policyListSearchSchema = z.object({
  status: z.enum([&#39;active&#39;, &#39;expired&#39;, &#39;pending&#39;]).optional(),
  page: z.number().int().positive().catch(1),
  query: z.string().catch(&#39;&#39;),
});

export const Route = createFileRoute(&#39;/policies/&#39;)({
  validateSearch: policyListSearchSchema,
  component: PolicyListRoute,
});

function PolicyListRoute() {
  const search = Route.useSearch();

  return &lt;PolicyListPage filters={search} /&gt;;
}
</code></pre>
<p>Now <code>search.page</code> is a number. Not a string you parsed manually. Not <code>string | undefined</code>. A number.</p>
<p>This matters because search params are the easiest place for type lies to enter an app. The browser gives you strings. Your product wants domain state. TanStack Router gives you a validation boundary.</p>
<h2>Updating Search Without Losing State</h2>
<p>When the user changes a filter:</p>
<pre><code class="language-tsx">import { useNavigate } from &#39;@tanstack/react-router&#39;;

export function PolicyFilters({ filters }: { filters: PolicyListFilters }) {
  const navigate = useNavigate({ from: &#39;/policies/&#39; });

  return (
    &lt;StatusSelect
      value={filters.status}
      onChange={(status) =&gt; {
        navigate({
          search: (prev) =&gt; ({
            ...prev,
            status,
            page: 1,
          }),
        });
      }}
    /&gt;
  );
}
</code></pre>
<p>Notice the <code>page: 1</code>.</p>
<p>Changing status should reset pagination. This is a product rule. Put it where navigation state changes. Otherwise you end up on page 7 of a filter that only has one page and someone files a &quot;missing policies&quot; bug.</p>
<p>For pagination:</p>
<pre><code class="language-tsx">export function PolicyPagination({ page }: { page: number }) {
  const navigate = useNavigate({ from: &#39;/policies/&#39; });

  return (
    &lt;Pagination
      page={page}
      onPageChange={(nextPage) =&gt; {
        navigate({
          search: (prev) =&gt; ({
            ...prev,
            page: nextPage,
          }),
        });
      }}
    /&gt;
  );
}
</code></pre>
<p>No local <code>useState</code>. The URL is the state.</p>
<h2>Connecting Router to Query</h2>
<p>Article #5 covered query options. Routing is where they pay off.</p>
<pre><code class="language-tsx">// src/routes/policies/index.tsx
export const Route = createFileRoute(&#39;/policies/&#39;)({
  validateSearch: policyListSearchSchema,
  loaderDeps: ({ search }) =&gt; ({ search }),
  loader: ({ context, deps }) =&gt;
    context.queryClient.ensureQueryData(policiesQueryOptions(deps.search)),
  component: PolicyListRoute,
});
</code></pre>
<p>The route validates search. The loader uses validated search to warm the query cache. The page subscribes to the same query:</p>
<pre><code class="language-tsx">function PolicyListRoute() {
  const search = Route.useSearch();
  const policiesQuery = useQuery(policiesQueryOptions(search));

  return &lt;PolicyListPage filters={search} policiesQuery={policiesQuery} /&gt;;
}
</code></pre>
<p>One data definition. Two usages.</p>
<p>That is the shape I want: route loaders handle navigation-level data readiness; components still use Query for cache subscription, background refetching, and UI state.</p>
<p>Don&#39;t fetch in the loader and pass raw data into the component while also using Query elsewhere. That creates two data paths. Use the loader to fill the cache.</p>
<h2>Detail Routes and Not Found</h2>
<p>For detail pages:</p>
<pre><code class="language-tsx">// src/routes/policies/$policyId.tsx
import { createFileRoute, notFound } from &#39;@tanstack/react-router&#39;;
import { policyDetailQueryOptions } from &#39;@/entities/policy&#39;;
import { isNotFoundError } from &#39;@/shared/api&#39;;
import { PolicyDetailPage } from &#39;@/pages/policy-detail&#39;;

export const Route = createFileRoute(&#39;/policies/$policyId&#39;)({
  loader: async ({ context, params }) =&gt; {
    try {
      return await context.queryClient.ensureQueryData(
        policyDetailQueryOptions(params.policyId),
      );
    } catch (error) {
      if (isNotFoundError(error)) throw notFound();
      throw error;
    }
  },
  component: PolicyDetailRoute,
  notFoundComponent: PolicyNotFound,
  errorComponent: PolicyRouteError,
});

function PolicyDetailRoute() {
  const { policyId } = Route.useParams();
  const policyQuery = useQuery(policyDetailQueryOptions(policyId));

  return &lt;PolicyDetailPage policy={policyQuery.data} /&gt;;
}
</code></pre>
<p>The route decides whether the page can exist. A missing policy is not the same as a temporary server failure. This lines up with the error-handling model from article #4.</p>
<h2>Route Guards with beforeLoad</h2>
<p>Some routes require authentication or permissions.</p>
<p>Use <code>beforeLoad</code> for route-level guards:</p>
<pre><code class="language-tsx">// src/routes/admin.tsx
import { createFileRoute, redirect } from &#39;@tanstack/react-router&#39;;
import { AdminPage } from &#39;@/pages/admin&#39;;

export const Route = createFileRoute(&#39;/admin&#39;)({
  beforeLoad: ({ context, location }) =&gt; {
    if (!context.auth) {
      throw redirect({
        to: &#39;/login&#39;,
        search: {
          redirectTo: location.href,
        },
      });
    }

    if (context.auth.role !== &#39;admin&#39;) {
      throw redirect({ to: &#39;/policies&#39; });
    }
  },
  component: AdminPage,
});
</code></pre>
<p>This is not a replacement for backend authorization. Never trust the frontend for security.</p>
<p>But it is the right place to keep users out of UI they cannot use. It also preserves intent: after login, send them back to where they tried to go.</p>
<h2>Preloading on Intent</h2>
<p>Routing should feel instant when intent is clear.</p>
<p>TanStack Router can preload routes through links. TanStack Query can prefetch query data. Use both deliberately.</p>
<pre><code class="language-tsx">export function PolicyRow({ policy }: { policy: Policy }) {
  return (
    &lt;Link
      to=&quot;/policies/$policyId&quot;
      params={{ policyId: policy.id }}
      preload=&quot;intent&quot;
    &gt;
      {policy.policyNumber}
    &lt;/Link&gt;
  );
}
</code></pre>
<p>When the user hovers or focuses the link, the router can prepare the destination. If your route loader calls <code>ensureQueryData</code>, the query cache warms too.</p>
<p>That is the clean integration: link intent -&gt; route preload -&gt; loader -&gt; query cache.</p>
<p>The user clicks, and the page is already halfway there.</p>
<h2>Use Route APIs Inside Route Components</h2>
<p>TanStack Router gives each route a <code>Route</code> object. Use it.</p>
<pre><code class="language-tsx">function PolicyListRoute() {
  const search = Route.useSearch();
  const navigate = Route.useNavigate();

  // ...
}
</code></pre>
<p>This is more precise than global hooks when you know which route you are in. The types are anchored to that route. Refactors stay local.</p>
<p>In leaf page components, I still prefer passing explicit props:</p>
<pre><code class="language-tsx">function PolicyListRoute() {
  const search = Route.useSearch();
  const policiesQuery = useQuery(policiesQueryOptions(search));

  return &lt;PolicyListPage filters={search} policiesQuery={policiesQuery} /&gt;;
}
</code></pre>
<p>Why not let <code>PolicyListPage</code> call <code>Route.useSearch()</code> directly?</p>
<p>Sometimes that is fine. But I like keeping page components easier to test and less coupled to router internals. The route knows about routing. The page knows about rendering the product view.</p>
<h2>Don&#39;t Hide Navigation in Random Helpers</h2>
<p>One pattern I avoid:</p>
<pre><code class="language-ts">export function openPolicy(policyId: string) {
  router.navigate({ to: &#39;/policies/$policyId&#39;, params: { policyId } });
}
</code></pre>
<p>It looks convenient. It spreads navigation decisions into random helpers. Over time, you get hidden redirects, surprising history behavior, and navigation that bypasses route-local search state.</p>
<p>Prefer explicit links and route-local navigation. If a navigation pattern repeats, create a component:</p>
<pre><code class="language-tsx">export function PolicyLink({
  policyId,
  children,
}: {
  policyId: string;
  children: React.ReactNode;
}) {
  return (
    &lt;Link to=&quot;/policies/$policyId&quot; params={{ policyId }} preload=&quot;intent&quot;&gt;
      {children}
    &lt;/Link&gt;
  );
}
</code></pre>
<p>Now navigation stays visible in the UI tree.</p>
<h2>The Review Checklist</h2>
<p>When I review routing code, I ask:</p>
<ul>
<li>Is shareable product state in the URL?</li>
<li>Are search params validated at the route boundary?</li>
<li>Do search changes preserve or reset related state intentionally?</li>
<li>Do route loaders reuse TanStack Query options instead of inventing separate fetch paths?</li>
<li>Are detail routes handling not-found separately from server errors?</li>
<li>Are protected routes guarded in <code>beforeLoad</code>?</li>
<li>Are links using typed <code>to</code>, <code>params</code>, and <code>search</code> instead of string concatenation?</li>
<li>Is preloading used where user intent is clear?</li>
<li>Are page components receiving explicit props when that keeps them easier to test?</li>
</ul>
<p>Most routing bugs are not about paths. They are about state that should have been in the URL but wasn&#39;t.</p>
<h2>What Comes Next</h2>
<p>With Query and Router in place, the next question is client state: the data that does not live on the server but still becomes too complex for <code>useState</code>.</p>
<p><strong>Article #7:</strong> <a href="https://ma-x.im/blog/react-playbook-reatom-state-management">state management with Reatom</a> — why I use it in CRM-scale React apps, where it fits next to TanStack Query, and why I don&#39;t reach for Zustand by default in this particular stack.</p>
<hr>
<p>After the policy-list support ticket, we moved filters, page, and search into route search params. The bug disappeared. More importantly, the product started behaving like a web app again. Refresh worked. Back worked. Shared links worked. QA could send exact states to developers instead of screenshots and vague reproduction steps.</p>
<p>That is what routing should do.</p>
<p>Not just render components.</p>
<p>Preserve intent.</p>
<p>If your React app has pages that look shareable but aren&#39;t, start with the URL. The fix is often not another store. It is admitting that the route already owns more state than your code says it does.</p>
]]></content:encoded>
      <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>TanStack Router</category>
      <category>Routing</category>
      <category>TypeScript</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>React Data Fetching with TanStack Query: The Patterns That Keep Apps Calm</title>
      <link>https://ma-x.im/blog/react-playbook-data-fetching</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-data-fetching</guid>
      <description>TanStack Query is not just a better useEffect. It is the server-state layer of your React app. The hard part is not fetching data — it is designing keys, freshness, invalidation, pagination, and prefetching so the UI stays predictable.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-data-fetching/cover.png" alt="React Data Fetching with TanStack Query: The Patterns That Keep Apps Calm" /></p>
<p>Three months into the insurance CRM, we had a page that felt haunted.</p>
<p>The policy list would load. Then it would blink. Then it would refetch after switching tabs. Then a status change would update one row but leave another count stale. Sometimes the detail page showed fresh data. Sometimes it showed yesterday&#39;s version until someone manually refreshed the browser.</p>
<p>Nobody had written obviously bad code.</p>
<p>There was a <code>useEffect</code> here, a loading boolean there, a bit of local state for filters, a manual refetch after save, and a few defensive checks around undefined data. Each decision made sense in isolation. Together, they formed the kind of frontend state machine nobody wants to debug.</p>
<p>I&#39;ve seen this pattern more times than I can count. Teams think their data-fetching problem is &quot;how do we call the API?&quot; That is the easy part. The real problem is ownership: who decides whether data is fresh, where it is cached, what invalidates it, what happens during pagination, and what the user sees while the network is moving.</p>
<p>That is why TanStack Query matters.</p>
<p>Not because it fetches.</p>
<p>Because it gives server state a home.</p>
<blockquote>
<p>The hardest part of data fetching is not the request. It is the lifecycle around the request.</p>
</blockquote>
<p>This is article #5 in the <strong>React Playbook</strong> series. We already covered the starting stack, TypeScript patterns, code structure, and error handling. Now we get to the layer that makes most React apps feel either calm or twitchy: TanStack Query.</p>
<h2>Server State Is Not Client State</h2>
<p>The first mental shift is simple but important.</p>
<p>Server state is not yours.</p>
<p>It lives somewhere else. It can change without your app knowing. It can be stale. It can fail. It can be shared by multiple users. It has a network boundary, a cache lifetime, and a synchronization problem.</p>
<p>Client state is different. A dialog is open or closed. A sidebar is collapsed. A draft field has local input. That state belongs to the browser session.</p>
<p>The mistake is mixing the two.</p>
<pre><code class="language-tsx">const [policies, setPolicies] = useState&lt;Policy[]&gt;([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState&lt;Error | null&gt;(null);

useEffect(() =&gt; {
  setIsLoading(true);

  fetchPolicies()
    .then(setPolicies)
    .catch(setError)
    .finally(() =&gt; setIsLoading(false));
}, []);
</code></pre>
<p>This looks harmless. It is fine for a demo. In production, it immediately raises questions:</p>
<ul>
<li>What happens when another component needs the same policies?</li>
<li>How long is this data fresh?</li>
<li>Who retries?</li>
<li>Who cancels the request if the component unmounts?</li>
<li>What invalidates this list after a mutation?</li>
<li>What happens when filters change?</li>
<li>What happens when the user leaves and comes back?</li>
</ul>
<p>You can answer all of those manually. You can also spend the next year rediscovering why caching is hard.</p>
<p>TanStack Query is the place where those answers live.</p>
<h2>Start with Query Options, Not Custom Hooks Everywhere</h2>
<p>My default pattern in larger apps is to define query options at the entity boundary.</p>
<pre><code class="language-ts">// src/entities/policy/api/policy.queries.ts
import { queryOptions } from &#39;@tanstack/react-query&#39;;
import { fetchPolicies, fetchPolicyById } from &#39;./policy.api&#39;;
import type { PolicyListFilters } from &#39;../model/policy.types&#39;;

export const policyKeys = {
  all: [&#39;policies&#39;] as const,
  lists: () =&gt; [...policyKeys.all, &#39;list&#39;] as const,
  list: (filters: PolicyListFilters) =&gt; [...policyKeys.lists(), filters] as const,
  details: () =&gt; [...policyKeys.all, &#39;detail&#39;] as const,
  detail: (policyId: string) =&gt; [...policyKeys.details(), policyId] as const,
};

export function policiesQueryOptions(filters: PolicyListFilters) {
  return queryOptions({
    queryKey: policyKeys.list(filters),
    queryFn: () =&gt; fetchPolicies(filters),
    staleTime: 1000 * 60 * 2,
  });
}

export function policyDetailQueryOptions(policyId: string) {
  return queryOptions({
    queryKey: policyKeys.detail(policyId),
    queryFn: () =&gt; fetchPolicyById(policyId),
    staleTime: 1000 * 60 * 5,
  });
}
</code></pre>
<p>Then components use the options:</p>
<pre><code class="language-tsx">// src/pages/policy-list/PolicyListPage.tsx
import { useQuery } from &#39;@tanstack/react-query&#39;;
import { policiesQueryOptions } from &#39;@/entities/policy&#39;;

export function PolicyListPage() {
  const filters = usePolicyListFilters();
  const policiesQuery = useQuery(policiesQueryOptions(filters));

  if (policiesQuery.isPending) return &lt;PolicyListSkeleton /&gt;;
  if (policiesQuery.isError) return &lt;PolicyListError onRetry={() =&gt; policiesQuery.refetch()} /&gt;;

  return &lt;PolicyTable policies={policiesQuery.data.items} /&gt;;
}
</code></pre>
<p>Why not just create <code>usePoliciesQuery(filters)</code>?</p>
<p>Sometimes I do. But query options compose better. The same object can be used by <code>useQuery</code>, route loaders, prefetching, tests, and invalidation helpers. It keeps the query definition in one place without forcing every usage through a hook.</p>
<p>The hook becomes optional sugar, not the source of truth.</p>
<h2>Query Keys Are Architecture</h2>
<p>Query keys look like small arrays. They are not small.</p>
<p>They are the identity of your server state.</p>
<p>If your keys are inconsistent, invalidation becomes guesswork:</p>
<pre><code class="language-ts">useQuery({ queryKey: [&#39;policy&#39;, policyId], queryFn: ... });
useQuery({ queryKey: [&#39;policies&#39;, &#39;detail&#39;, id], queryFn: ... });
queryClient.invalidateQueries({ queryKey: [&#39;policy&#39;] });
</code></pre>
<p>This is how stale UI survives after a successful mutation.</p>
<p>Use factories:</p>
<pre><code class="language-ts">export const policyKeys = {
  all: [&#39;policies&#39;] as const,
  lists: () =&gt; [...policyKeys.all, &#39;list&#39;] as const,
  list: (filters: PolicyListFilters) =&gt; [...policyKeys.lists(), filters] as const,
  details: () =&gt; [...policyKeys.all, &#39;detail&#39;] as const,
  detail: (policyId: string) =&gt; [...policyKeys.details(), policyId] as const,
};
</code></pre>
<p>This gives you hierarchy:</p>
<pre><code class="language-ts">queryClient.invalidateQueries({ queryKey: policyKeys.lists() });
queryClient.invalidateQueries({ queryKey: policyKeys.detail(policyId) });
queryClient.invalidateQueries({ queryKey: policyKeys.all });
</code></pre>
<p>Invalidate all policies. Or only lists. Or one detail. The key shape makes the choice explicit.</p>
<p>The rule is straightforward:</p>
<blockquote>
<p>If a variable changes what the query fetches, that variable belongs in the query key.</p>
</blockquote>
<p>Filters, page number, cursor, search term, sort order, preview mode, tenant id. Put them in the key. TanStack Query cannot cache what you do not identify.</p>
<h2>Freshness Is a Product Decision</h2>
<p>TanStack Query&#39;s defaults are intentionally active: cached data is stale by default, stale queries refetch on mount, window focus, and reconnect, and inactive queries are garbage-collected after a default cache window.</p>
<p>Those defaults are good for learning and safe for many apps. They are not a substitute for product thinking.</p>
<p>In a CRM, different data has different freshness needs.</p>
<pre><code class="language-ts">export function policyDetailQueryOptions(policyId: string) {
  return queryOptions({
    queryKey: policyKeys.detail(policyId),
    queryFn: () =&gt; fetchPolicyById(policyId),
    staleTime: 1000 * 60 * 5,
  });
}

export function policyActivityQueryOptions(policyId: string) {
  return queryOptions({
    queryKey: [...policyKeys.detail(policyId), &#39;activity&#39;],
    queryFn: () =&gt; fetchPolicyActivity(policyId),
    staleTime: 1000 * 30,
  });
}

export function coverageTypesQueryOptions() {
  return queryOptions({
    queryKey: [&#39;coverage-types&#39;],
    queryFn: fetchCoverageTypes,
    staleTime: 1000 * 60 * 60,
    gcTime: 1000 * 60 * 60,
  });
}
</code></pre>
<p>Policy detail can be fresh for five minutes. Activity should refresh more aggressively. Coverage types barely change during a session.</p>
<p>This is the kind of decision I want visible in code. If every query inherits one global <code>staleTime</code>, you are pretending all data has the same volatility.</p>
<p>It doesn&#39;t.</p>
<h2>Background Fetching Without UI Jank</h2>
<p>One of TanStack Query&#39;s best features is that it separates initial loading from background fetching.</p>
<pre><code class="language-tsx">export function PolicyListPage() {
  const filters = usePolicyListFilters();
  const policiesQuery = useQuery(policiesQueryOptions(filters));

  if (policiesQuery.isPending) return &lt;PolicyListSkeleton /&gt;;
  if (policiesQuery.isError) return &lt;PolicyListError onRetry={() =&gt; policiesQuery.refetch()} /&gt;;

  return (
    &lt;&gt;
      {policiesQuery.isFetching &amp;&amp; &lt;RefreshingIndicator /&gt;}
      &lt;PolicyTable policies={policiesQuery.data.items} /&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p><code>isPending</code> means there is no data yet.</p>
<p><code>isFetching</code> means a request is currently happening.</p>
<p>Those are not the same UX. If you show a full-page skeleton every time <code>isFetching</code> is true, your app will blink constantly. Keep the old data visible. Show a small refreshing indicator if the user needs to know.</p>
<p>This is one of the places where React apps start feeling calm.</p>
<h2>Pagination Without Flicker</h2>
<p>Pagination is where naive data fetching becomes obvious.</p>
<pre><code class="language-tsx">const policiesQuery = useQuery({
  queryKey: [&#39;policies&#39;, page],
  queryFn: () =&gt; fetchPolicies({ page }),
});
</code></pre>
<p>Technically correct. Bad experience.</p>
<p>Every page is a different query key, so moving from page 1 to page 2 can bounce the UI back into a pending state. For tables, that feels like the screen is being rebuilt under the user&#39;s hands.</p>
<p>Use <code>placeholderData: keepPreviousData</code>:</p>
<pre><code class="language-tsx">import { keepPreviousData, useQuery } from &#39;@tanstack/react-query&#39;;

export function PolicyListPage() {
  const [page, setPage] = useState(1);
  const filters = usePolicyListFilters();

  const policiesQuery = useQuery({
    ...policiesQueryOptions({ ...filters, page }),
    placeholderData: keepPreviousData,
  });

  if (policiesQuery.isPending) return &lt;PolicyListSkeleton /&gt;;
  if (policiesQuery.isError) return &lt;PolicyListError onRetry={() =&gt; policiesQuery.refetch()} /&gt;;

  return (
    &lt;&gt;
      &lt;PolicyTable
        policies={policiesQuery.data.items}
        faded={policiesQuery.isPlaceholderData}
      /&gt;
      &lt;Pagination
        page={page}
        hasNextPage={policiesQuery.data.hasNextPage}
        disabled={policiesQuery.isPlaceholderData}
        onPageChange={setPage}
      /&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>Now the previous page stays visible while the next page loads. When the new data arrives, it swaps in. The UI doesn&#39;t fall through loading states just because the query key changed.</p>
<p>Small detail. Huge difference.</p>
<h2>Dependent Queries</h2>
<p>Some data needs another piece of data first.</p>
<p>The policy detail page loads the policy. Then it may load documents for the policy&#39;s customer account. You can&#39;t fetch those documents until you know the account id.</p>
<pre><code class="language-tsx">export function PolicyDetailPage() {
  const { policyId } = Route.useParams();

  const policyQuery = useQuery(policyDetailQueryOptions(policyId));

  const documentsQuery = useQuery({
    ...customerDocumentsQueryOptions(policyQuery.data?.customerAccountId ?? &#39;&#39;),
    enabled: Boolean(policyQuery.data?.customerAccountId),
  });

  // ...
}
</code></pre>
<p>The <code>enabled</code> flag is the important part. It says: this query exists, but it should not run until its input exists.</p>
<p>Don&#39;t put conditional hooks around this:</p>
<pre><code class="language-tsx">if (policyQuery.data) {
  const documentsQuery = useQuery(...);
}
</code></pre>
<p>React hooks don&#39;t work that way. Keep the hook unconditional. Control execution with <code>enabled</code>.</p>
<h2>Prefetch at the Route Boundary</h2>
<p>TanStack Router and TanStack Query fit together cleanly because route loaders can ensure query data before the page renders.</p>
<pre><code class="language-ts">// src/pages/policy-detail/policy-detail.route.ts
export const policyDetailRoute = createRoute({
  getParentRoute: () =&gt; rootRoute,
  path: &#39;/policies/$policyId&#39;,
  loader: ({ context, params }) =&gt;
    context.queryClient.ensureQueryData(policyDetailQueryOptions(params.policyId)),
  component: PolicyDetailPage,
});
</code></pre>
<p>Then inside the page:</p>
<pre><code class="language-tsx">export function PolicyDetailPage() {
  const { policyId } = Route.useParams();
  const policyQuery = useQuery(policyDetailQueryOptions(policyId));

  return &lt;PolicyDetailView policy={policyQuery.data} /&gt;;
}
</code></pre>
<p>The page still uses <code>useQuery</code>. That is good. The loader warms the cache; the component subscribes to it. You get route-level loading behavior without inventing a second data path.</p>
<p>For list-to-detail navigation, prefetch on intent:</p>
<pre><code class="language-tsx">function PolicyRow({ policy }: { policy: Policy }) {
  const queryClient = useQueryClient();

  return (
    &lt;Link
      to=&quot;/policies/$policyId&quot;
      params={{ policyId: policy.id }}
      onMouseEnter={() =&gt; {
        queryClient.prefetchQuery(policyDetailQueryOptions(policy.id));
      }}
    &gt;
      {policy.policyNumber}
    &lt;/Link&gt;
  );
}
</code></pre>
<p>Hover is not the only signal. Focus works for keyboard users. Intersection works for visible rows. The point is the same: fetch before the click when intent is clear enough.</p>
<h2>Mutations Should Invalidate Narrowly</h2>
<p>Mutations deserve their own full article later, but invalidation belongs here.</p>
<p>After editing a policy status:</p>
<pre><code class="language-ts">export function useEditPolicyStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updatePolicyStatus,
    onSuccess: (updatedPolicy) =&gt; {
      queryClient.setQueryData(
        policyKeys.detail(updatedPolicy.id),
        updatedPolicy,
      );

      queryClient.invalidateQueries({ queryKey: policyKeys.lists() });
    },
  });
}
</code></pre>
<p>Two things happen:</p>
<ol>
<li>The detail cache gets the exact updated policy immediately.</li>
<li>Policy lists are invalidated because that status might affect filters, counts, and visible rows.</li>
</ol>
<p>I prefer this over invalidating everything:</p>
<pre><code class="language-ts">queryClient.invalidateQueries();
</code></pre>
<p>That is the data-fetching equivalent of turning the lights off and on again. Sometimes useful during development. Rarely the right production behavior.</p>
<p>Be specific. Your users can feel the difference.</p>
<h2>Don&#39;t Mirror Query Data Into Local State</h2>
<p>This is the smell I look for in reviews:</p>
<pre><code class="language-tsx">const policiesQuery = useQuery(policiesQueryOptions(filters));
const [policies, setPolicies] = useState&lt;Policy[]&gt;([]);

useEffect(() =&gt; {
  if (policiesQuery.data) {
    setPolicies(policiesQuery.data.items);
  }
}, [policiesQuery.data]);
</code></pre>
<p>Now there are two sources of truth. Query cache and local state. They will drift.</p>
<p>Usually the fix is simple:</p>
<pre><code class="language-tsx">const policies = policiesQuery.data?.items ?? [];
</code></pre>
<p>If you need derived data, derive it:</p>
<pre><code class="language-tsx">const activePolicies = useMemo(
  () =&gt; policiesQuery.data?.items.filter((policy) =&gt; policy.status === &#39;active&#39;) ?? [],
  [policiesQuery.data?.items],
);
</code></pre>
<p>If you need editable draft state, make that explicit:</p>
<pre><code class="language-tsx">const policyQuery = useQuery(policyDetailQueryOptions(policyId));
const form = useForm({
  defaultValues: policyQuery.data,
});
</code></pre>
<p>Server data can seed a draft. Once it becomes a draft, it is client state. Name the transition. Don&#39;t hide it in a <code>useEffect</code>.</p>
<h2>The Review Checklist</h2>
<p>When I review TanStack Query code, I ask:</p>
<ul>
<li>Does every query key include every variable used by the query function?</li>
<li>Are keys created through a factory instead of hand-written arrays everywhere?</li>
<li>Does each query have a deliberate <code>staleTime</code>?</li>
<li>Is pagination using <code>placeholderData</code> when flicker would hurt?</li>
<li>Are dependent queries controlled with <code>enabled</code>?</li>
<li>Are route loaders using the same query options as components?</li>
<li>Do mutations invalidate the narrowest useful key?</li>
<li>Is query data being mirrored into local state unnecessarily?</li>
<li>Does the UI distinguish first load from background refetch?</li>
</ul>
<p>That checklist catches most of the data bugs I see in React applications.</p>
<h2>What Comes Next</h2>
<p>TanStack Query is the server-state layer. Once that layer is stable, routing becomes much easier to reason about: routes can preload the same query options, search params can drive query keys, and navigation can become type-safe instead of stringly typed.</p>
<p><strong>Article #6:</strong> <a href="https://ma-x.im/blog/react-playbook-tanstack-router">routing with TanStack Router</a> — route trees, typed params, search validation, loaders, navigation, and how Router and Query should meet without turning every page into glue code.</p>
<hr>
<p>The haunted policy list eventually became boring. Not because we added more spinners. Because the data model became explicit. Query keys had hierarchy. Freshness was intentional. Mutations invalidated the right caches. Pagination stopped flickering. Route loaders warmed the detail page before the user landed there.</p>
<p>That is what I want from data fetching in React.</p>
<p>Not cleverness. Calm.</p>
<p>If your React app has started to feel like the UI is arguing with the network, reach out. The fix is often not another loading state. It is giving server state one clear owner.</p>
]]></content:encoded>
      <pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>TanStack Query</category>
      <category>Data Fetching</category>
      <category>TypeScript</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Error Handling in React: What Users Should See When Things Break</title>
      <link>https://ma-x.im/blog/react-playbook-error-handling</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-error-handling</guid>
      <description>Error handling is not a catch block sprinkled at the end. In a React + TanStack app, it is a product decision: what fails locally, what fails at the route level, what can retry, and what the user needs to do next.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-error-handling/cover.png" alt="Error Handling in React: What Users Should See When Things Break" /></p>
<p>The first serious error-handling conversation on the insurance CRM happened after a demo.</p>
<p>We had built the policy detail page. It fetched the policy, loaded the customer, showed coverage sections, rendered a timeline, and pulled a list of recent changes. The happy path looked good. Fast enough. Clean enough. The kind of screen that makes a product manager relax for about five minutes.</p>
<p>Then the staging API returned a 500 for the timeline.</p>
<p>The whole page disappeared.</p>
<p>Not the timeline. Not the lower section. The entire policy detail page. A blank error screen replaced the policy number, the customer information, the coverage details, everything the agent actually needed to keep working.</p>
<p>The backend bug was real. But the frontend decision was worse: we had treated one failed widget as if the whole route was unusable.</p>
<p>I&#39;ve watched this happen in multiple React apps. Error handling gets added late, after the components are built and the data flow already exists. Someone wraps the app in an Error Boundary, adds a few <code>isError</code> checks, maybe logs to Sentry, and calls it done.</p>
<p>It is not done.</p>
<p>Error handling is not just catching failures. It is deciding the blast radius of a failure.</p>
<blockquote>
<p>A good error boundary is not a wall around your app. It is a circuit breaker around the smallest thing that can safely fail.</p>
</blockquote>
<p>This article continues the <strong>React Playbook</strong> foundation. We already covered the stack, TypeScript patterns, and code structure. Now we need to talk about what happens when the happy path breaks: React Error Boundaries, TanStack Query error states, TanStack Router route errors, mutation failures, and the messages users actually deserve.</p>
<h2>First, Separate Error Types</h2>
<p>Most frontend error handling gets messy because teams put every failure in the same mental bucket.</p>
<p>They are not the same.</p>
<p>In a React app, I separate five categories:</p>
<ol>
<li><strong>Render errors</strong> — a component throws while rendering.</li>
<li><strong>Route loading errors</strong> — a route cannot load the data required to show the page.</li>
<li><strong>Query errors</strong> — a specific async resource failed.</li>
<li><strong>Mutation errors</strong> — a user action failed.</li>
<li><strong>Validation errors</strong> — user input is invalid before the request should happen.</li>
</ol>
<p>Each one needs a different UX.</p>
<p>A render error is usually unexpected. You need containment, logging, and a recovery path.</p>
<p>A route loading error might mean the page cannot exist. If <code>/policies/$policyId</code> cannot load the policy at all, the page cannot show much.</p>
<p>A query error might only affect part of the screen. If recent activity fails, the user can still read the policy.</p>
<p>A mutation error happens after intent. The user clicked Save, Submit, Assign, Delete. The UI needs to explain what happened and preserve their work.</p>
<p>A validation error is not exceptional at all. It is part of the form flow.</p>
<p>When you model these separately, the code gets simpler. More importantly, the product gets calmer.</p>
<h2>App-Level Error Boundary</h2>
<p>React Error Boundaries catch errors thrown during rendering, lifecycle methods, and constructors of child components. They do not catch async errors from promises by default. TanStack Query and Router have their own integration points, which we&#39;ll get to.</p>
<p>For the app shell, you still want a top-level boundary:</p>
<pre><code class="language-tsx">// src/app/AppErrorBoundary.tsx
import { ErrorBoundary } from &#39;react-error-boundary&#39;;

interface AppErrorFallbackProps {
  error: Error;
  resetErrorBoundary: () =&gt; void;
}

function AppErrorFallback({ error, resetErrorBoundary }: AppErrorFallbackProps) {
  return (
    &lt;main className=&quot;mx-auto flex min-h-screen max-w-xl flex-col justify-center p-6&quot;&gt;
      &lt;h1 className=&quot;text-2xl font-semibold&quot;&gt;Something went wrong&lt;/h1&gt;
      &lt;p className=&quot;mt-3 text-muted-foreground&quot;&gt;
        The application hit an unexpected error. You can try again, or refresh the page if the
        problem continues.
      &lt;/p&gt;
      &lt;pre className=&quot;mt-4 rounded-md bg-muted p-3 text-sm&quot;&gt;{error.message}&lt;/pre&gt;
      &lt;button className=&quot;mt-6&quot; onClick={resetErrorBoundary}&gt;
        Try again
      &lt;/button&gt;
    &lt;/main&gt;
  );
}

export function AppErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    &lt;ErrorBoundary
      FallbackComponent={AppErrorFallback}
      onError={(error, info) =&gt; {
        reportError(error, { componentStack: info.componentStack });
      }}
    &gt;
      {children}
    &lt;/ErrorBoundary&gt;
  );
}
</code></pre>
<p>Then wire it at the application edge:</p>
<pre><code class="language-tsx">// src/app/providers.tsx
import { QueryClientProvider } from &#39;@tanstack/react-query&#39;;
import { AppErrorBoundary } from &#39;./AppErrorBoundary&#39;;
import { queryClient } from &#39;./query-client&#39;;

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    &lt;AppErrorBoundary&gt;
      &lt;QueryClientProvider client={queryClient}&gt;{children}&lt;/QueryClientProvider&gt;
    &lt;/AppErrorBoundary&gt;
  );
}
</code></pre>
<p>This is the last line of defense. It should almost never be the boundary your users see during normal failures.</p>
<p>If a single chart crashes, don&#39;t replace the entire app. If a sidebar widget crashes, don&#39;t hide the working page. Put smaller boundaries around risky areas.</p>
<h2>Section-Level Boundaries</h2>
<p>The policy detail page had several independent sections:</p>
<ul>
<li>policy header</li>
<li>coverage summary</li>
<li>beneficiaries</li>
<li>recent activity timeline</li>
<li>document attachments</li>
</ul>
<p>The timeline was useful, but not essential. A timeline failure should not kill the route.</p>
<p>So give it a local boundary:</p>
<pre><code class="language-tsx">// src/widgets/recent-policy-activity/RecentPolicyActivityBoundary.tsx
import { ErrorBoundary } from &#39;react-error-boundary&#39;;
import { RecentPolicyActivity } from &#39;./RecentPolicyActivity&#39;;

function RecentPolicyActivityFallback() {
  return (
    &lt;section className=&quot;rounded-md border p-4&quot;&gt;
      &lt;h2 className=&quot;font-medium&quot;&gt;Recent activity&lt;/h2&gt;
      &lt;p className=&quot;mt-2 text-sm text-muted-foreground&quot;&gt;
        Activity could not be loaded right now. The policy details are still available.
      &lt;/p&gt;
    &lt;/section&gt;
  );
}

export function RecentPolicyActivityBoundary({ policyId }: { policyId: string }) {
  return (
    &lt;ErrorBoundary FallbackComponent={RecentPolicyActivityFallback}&gt;
      &lt;RecentPolicyActivity policyId={policyId} /&gt;
    &lt;/ErrorBoundary&gt;
  );
}
</code></pre>
<p>The key sentence in the fallback is not &quot;An error occurred.&quot; The key sentence is: <strong>the policy details are still available.</strong></p>
<p>Users need to know scope. What broke? What still works? What can they do next?</p>
<h2>TanStack Query: Local Error States First</h2>
<p>TanStack Query gives you the query state directly:</p>
<pre><code class="language-tsx">const policyQuery = useQuery(policyDetailQueryOptions(policyId));

if (policyQuery.isPending) return &lt;PolicyDetailSkeleton /&gt;;
if (policyQuery.isError) return &lt;PolicyDetailError error={policyQuery.error} /&gt;;

return &lt;PolicyDetailView policy={policyQuery.data} /&gt;;
</code></pre>
<p>This is the right pattern when the query owns the whole component. The component has one main job: load policy detail and render it. If that query fails, the component cannot do its job.</p>
<p>Make the error component specific:</p>
<pre><code class="language-tsx">// src/entities/policy/ui/PolicyDetailError.tsx
interface PolicyDetailErrorProps {
  error: Error;
  onRetry: () =&gt; void;
}

export function PolicyDetailError({ error, onRetry }: PolicyDetailErrorProps) {
  return (
    &lt;div className=&quot;rounded-md border border-destructive/30 p-4&quot;&gt;
      &lt;h2 className=&quot;font-medium&quot;&gt;Policy could not be loaded&lt;/h2&gt;
      &lt;p className=&quot;mt-2 text-sm text-muted-foreground&quot;&gt;
        The policy may have been removed, or the server may be temporarily unavailable.
      &lt;/p&gt;
      &lt;p className=&quot;mt-2 text-xs text-muted-foreground&quot;&gt;{error.message}&lt;/p&gt;
      &lt;button className=&quot;mt-4&quot; onClick={onRetry}&gt;
        Retry
      &lt;/button&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Then pass the retry:</p>
<pre><code class="language-tsx">if (policyQuery.isError) {
  return &lt;PolicyDetailError error={policyQuery.error} onRetry={() =&gt; policyQuery.refetch()} /&gt;;
}
</code></pre>
<p>That <code>refetch()</code> matters. A dead error state with no recovery action forces the user to refresh the browser. That is not error handling. That is surrender.</p>
<h2>Configure Query Defaults Deliberately</h2>
<p>In article #1, we started with:</p>
<pre><code class="language-ts">// src/app/query-client.ts
import { QueryClient } from &#39;@tanstack/react-query&#39;;

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 1,
    },
  },
});
</code></pre>
<p><code>retry: 1</code> is not random. The TanStack Query default is three retries with exponential backoff. For some apps, that is fine. For internal CRM workflows, I usually prefer one retry.</p>
<p>Why?</p>
<p>Because three retries can make an error feel like a frozen screen. The user clicked a policy. The API is failing. The UI waits, retries, waits, retries, waits again, and only then shows an error. Technically resilient. Product-wise frustrating.</p>
<p>But not every query deserves the same retry behavior.</p>
<pre><code class="language-ts">export function policyDetailQueryOptions(policyId: string) {
  return queryOptions({
    queryKey: policyKeys.detail(policyId),
    queryFn: () =&gt; fetchPolicyById(policyId),
    retry: (failureCount, error) =&gt; {
      if (isNotFoundError(error)) return false;
      return failureCount &lt; 1;
    },
  });
}
</code></pre>
<p>Don&#39;t retry 404s. The policy isn&#39;t going to appear after a second attempt. Do retry one transient network failure. That is a reasonable default.</p>
<p>This is where typed API errors help.</p>
<h2>Typed API Errors</h2>
<p>In article #2, we talked about typing API responses at the boundary. Do the same for errors.</p>
<pre><code class="language-ts">// src/shared/api/api-error.ts
export class ApiError extends Error {
  constructor(
    message: string,
    public readonly status: number,
    public readonly code?: string,
  ) {
    super(message);
    this.name = &#39;ApiError&#39;;
  }
}

export function isApiError(error: unknown): error is ApiError {
  return error instanceof ApiError;
}

export function isNotFoundError(error: unknown) {
  return isApiError(error) &amp;&amp; error.status === 404;
}
</code></pre>
<p>Use it in the HTTP client:</p>
<pre><code class="language-ts">// src/shared/api/http-client.ts
import { ApiError } from &#39;./api-error&#39;;

export async function getJson&lt;T&gt;(url: string): Promise&lt;T&gt; {
  const response = await fetch(url);

  if (!response.ok) {
    const body = await response.json().catch(() =&gt; null);
    throw new ApiError(body?.message ?? `GET ${url} failed`, response.status, body?.code);
  }

  return response.json() as Promise&lt;T&gt;;
}
</code></pre>
<p>Now your UI can make product decisions:</p>
<pre><code class="language-tsx">function PolicyDetailError({ error, onRetry }: PolicyDetailErrorProps) {
  if (isNotFoundError(error)) {
    return (
      &lt;EmptyState
        title=&quot;Policy not found&quot;
        description=&quot;This policy may have been deleted or you may not have access to it.&quot;
      /&gt;
    );
  }

  return (
    &lt;ErrorState
      title=&quot;Policy could not be loaded&quot;
      description=&quot;The server did not return the policy. Try again in a moment.&quot;
      action={{ label: &#39;Retry&#39;, onClick: onRetry }}
    /&gt;
  );
}
</code></pre>
<p>A 404 and a 500 should not have the same message. One is probably permanent. One may be temporary. The UI should reflect that.</p>
<h2>Route-Level Errors with TanStack Router</h2>
<p>Some data is required before a route can render.</p>
<p>For a policy detail page, the policy itself is required. Recent activity is not. Attachments are not. Recommendations are not.</p>
<p>TanStack Router lets you load required data at the route level:</p>
<pre><code class="language-ts">// src/pages/policy-detail/policy-detail.route.ts
export const policyDetailRoute = createRoute({
  getParentRoute: () =&gt; rootRoute,
  path: &#39;/policies/$policyId&#39;,
  loader: ({ context, params }) =&gt;
    context.queryClient.ensureQueryData(policyDetailQueryOptions(params.policyId)),
  errorComponent: PolicyDetailRouteError,
  component: PolicyDetailPage,
});
</code></pre>
<p>Then the route error component owns route-level failures:</p>
<pre><code class="language-tsx">// src/pages/policy-detail/PolicyDetailRouteError.tsx
import { useRouter } from &#39;@tanstack/react-router&#39;;
import { isNotFoundError } from &#39;@/shared/api&#39;;

export function PolicyDetailRouteError({ error }: { error: unknown }) {
  const router = useRouter();

  if (isNotFoundError(error)) {
    return (
      &lt;RouteErrorLayout
        title=&quot;Policy not found&quot;
        description=&quot;The policy does not exist, was removed, or is not available to your account.&quot;
        action={{ label: &#39;Back to policies&#39;, to: &#39;/policies&#39; }}
      /&gt;
    );
  }

  return (
    &lt;RouteErrorLayout
      title=&quot;Policy could not be opened&quot;
      description=&quot;Something failed while loading this policy. You can retry without leaving the page.&quot;
      action={{ label: &#39;Retry&#39;, onClick: () =&gt; router.invalidate() }}
    /&gt;
  );
}
</code></pre>
<p><code>router.invalidate()</code> is the route-level retry. It re-runs loaders and gives the route a chance to recover.</p>
<p>The important design choice is deciding what belongs in the loader. If every widget query goes into the loader, every widget failure becomes a route failure. That is exactly how we blanked the policy detail page in the demo.</p>
<p>My rule:</p>
<blockquote>
<p>Load the minimum data required to make the route meaningful. Everything else should fail locally.</p>
</blockquote>
<h2>Mutation Errors Are Different</h2>
<p>Query errors happen while reading.</p>
<p>Mutation errors happen after the user did something.</p>
<p>That difference matters. A failed mutation should preserve the user&#39;s input, explain what happened, and make retry obvious.</p>
<pre><code class="language-tsx">// src/features/edit-policy-status/ui/EditPolicyStatusForm.tsx
export function EditPolicyStatusForm({ policyId }: { policyId: string }) {
  const editStatus = useEditPolicyStatus();

  return (
    &lt;form
      onSubmit={(event) =&gt; {
        event.preventDefault();
        editStatus.mutate({ policyId, status: &#39;active&#39; });
      }}
    &gt;
      {editStatus.isError &amp;&amp; (
        &lt;InlineError&gt;
          Status could not be updated. Your change was not saved. Try again in a moment.
        &lt;/InlineError&gt;
      )}

      &lt;button disabled={editStatus.isPending}&gt;
        {editStatus.isPending ? &#39;Saving...&#39; : &#39;Save status&#39;}
      &lt;/button&gt;
    &lt;/form&gt;
  );
}
</code></pre>
<p>Notice the wording: <strong>Your change was not saved.</strong></p>
<p>Users need to know whether the action happened. Ambiguous mutation errors are dangerous. Did the payment submit? Did the policy update? Did the customer receive the email? If the system cannot guarantee success, say so clearly.</p>
<p>For destructive actions, be even more explicit:</p>
<pre><code class="language-tsx">&lt;InlineError&gt;
  The document was not deleted. The server rejected the request, so it is still attached to the
  policy.
&lt;/InlineError&gt;
</code></pre>
<p>That kind of sentence sounds small. In operational software, it matters.</p>
<h2>Optimistic Updates Need Rollback</h2>
<p>TanStack Query makes optimistic updates straightforward, but optimistic error handling has to be designed.</p>
<pre><code class="language-ts">// src/features/edit-policy-status/api/edit-policy-status.mutation.ts
export function useEditPolicyStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updatePolicyStatus,
    onMutate: async (variables) =&gt; {
      await queryClient.cancelQueries({ queryKey: policyKeys.detail(variables.policyId) });

      const previousPolicy = queryClient.getQueryData&lt;PolicyDetail&gt;(
        policyKeys.detail(variables.policyId),
      );

      queryClient.setQueryData&lt;PolicyDetail&gt;(policyKeys.detail(variables.policyId), (policy) =&gt;
        policy ? { ...policy, status: variables.status } : policy,
      );

      return { previousPolicy };
    },
    onError: (_error, variables, context) =&gt; {
      queryClient.setQueryData(policyKeys.detail(variables.policyId), context?.previousPolicy);
    },
    onSettled: (_data, _error, variables) =&gt; {
      queryClient.invalidateQueries({ queryKey: policyKeys.detail(variables.policyId) });
    },
  });
}
</code></pre>
<p>Optimistic UI is a promise: &quot;we&#39;ll show the result now and clean it up if the server disagrees.&quot;</p>
<p>If you don&#39;t roll back, it becomes a lie.</p>
<p>Honestly, I use optimistic updates less often than people expect. They are excellent for low-risk interactions: toggles, lightweight status changes, local ordering. For payments, legal records, irreversible actions, or anything with compliance implications, I prefer boring pending states and clear confirmation.</p>
<p>Fast is good. Correct is better.</p>
<h2>Validation Errors Are Not Exceptions</h2>
<p>Form validation errors should not go through Error Boundaries, route error components, or global toasts.</p>
<p>They belong next to the field or section that needs correction.</p>
<pre><code class="language-tsx">// src/features/create-policy/ui/CreatePolicyForm.tsx
&lt;form.Field
  name=&quot;holderName&quot;
  validators={{
    onBlur: ({ value }) =&gt; {
      if (!value.trim()) return &#39;Policy holder name is required&#39;;
      return undefined;
    },
  }}
&gt;
  {(field) =&gt; (
    &lt;TextField
      label=&quot;Policy holder&quot;
      value={field.state.value}
      onBlur={field.handleBlur}
      onChange={(event) =&gt; field.handleChange(event.target.value)}
      error={field.state.meta.errors[0]}
    /&gt;
  )}
&lt;/form.Field&gt;
</code></pre>
<p>A validation message is not an apology. It is guidance.</p>
<p>Bad:</p>
<pre><code>Invalid value.
</code></pre>
<p>Better:</p>
<pre><code>Policy holder name is required.
</code></pre>
<p>Better still, when the rule is domain-specific:</p>
<pre><code>Coverage start date must be after the policy approval date.
</code></pre>
<p>The closer the error is to the user&#39;s mental model, the less support burden you create.</p>
<h2>Global Toasts Are Not an Error Strategy</h2>
<p>I have a strong bias here: global error toasts are overused.</p>
<p>They are fine for background actions:</p>
<pre><code>Report export failed. Try again later.
</code></pre>
<p>They are not fine as the only feedback for a failed form submit, failed route load, or failed section query.</p>
<p>Toasts disappear. They are hard to reread. They often don&#39;t say what changed. If the user needs the information to continue, put it in the interface where the failure happened.</p>
<p>Use toasts for secondary notifications. Use inline states for primary failures.</p>
<h2>Logging Without Leaking</h2>
<p>You still need error reporting.</p>
<p>But don&#39;t send everything everywhere. A useful report has context:</p>
<pre><code class="language-ts">// src/shared/lib/report-error.ts
interface ErrorContext {
  route?: string;
  policyId?: string;
  componentStack?: string;
  action?: string;
}

export function reportError(error: unknown, context: ErrorContext = {}) {
  if (import.meta.env.DEV) {
    console.error(error, context);
    return;
  }

  // Send to your reporting tool here.
  // Include context, but avoid sensitive policy/customer data.
}
</code></pre>
<p>For a CRM, this matters. Policy numbers, customer names, payment details, medical information, internal notes — these should not casually land in logs because a component threw.</p>
<p>Log identifiers when needed. Avoid payloads unless you have a reason and a retention policy.</p>
<h2>A Practical Error-Handling Checklist</h2>
<p>When I review a React screen, I ask these questions:</p>
<ul>
<li>What data is required for the route to be meaningful?</li>
<li>What data can fail locally without killing the page?</li>
<li>Does every failed query have a retry path?</li>
<li>Does every failed mutation preserve user input?</li>
<li>Does the user know whether their action was saved?</li>
<li>Are 404, 403, 409, and 500 treated differently where it matters?</li>
<li>Are validation errors close to the fields?</li>
<li>Are unexpected errors logged with useful context and without sensitive data?</li>
</ul>
<p>This is not bureaucracy. This is how you keep production software calm.</p>
<h2>What Comes Next</h2>
<p>The first four articles now cover the base layer of a serious React application:</p>
<ul>
<li><strong>Article #1:</strong> <a href="https://ma-x.im/blog/react-playbook-starting-new-project">the stack</a></li>
<li><strong>Article #2:</strong> <a href="https://ma-x.im/blog/react-playbook-typescript-patterns">TypeScript patterns</a></li>
<li><strong>Article #3:</strong> <a href="https://ma-x.im/blog/react-playbook-code-structure">code structure</a></li>
<li><strong>Article #4:</strong> this error-handling model</li>
</ul>
<p><strong>Article #5:</strong> <a href="https://ma-x.im/blog/react-playbook-data-fetching">data fetching with TanStack Query</a> — query keys, cache shape, prefetching, invalidation, stale time, pagination, and the boring details that decide whether a React app feels reliable or twitchy.</p>
<hr>
<p>After that staging demo, we changed the policy detail page. The policy itself stayed route-level. The timeline moved into a local query with its own fallback. Attachments got a retry button. Mutations kept user input visible after failure. The app did not become immune to backend problems. No frontend can do that.</p>
<p>But failures became smaller.</p>
<p>That is the whole point. Not hiding errors. Not pretending the system is fine. Making sure one broken piece does not take the rest of the work down with it.</p>
<p>If you&#39;re designing error handling for a React app and every failure currently becomes either a toast or a blank screen, reach out. There is usually a calmer architecture hiding one boundary lower.</p>
]]></content:encoded>
      <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Error Handling</category>
      <category>TanStack</category>
      <category>TypeScript</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Code Structure in React: The FSD Version I Actually Use</title>
      <link>https://ma-x.im/blog/react-playbook-code-structure</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-code-structure</guid>
      <description>Flat folders are fine until every feature starts touching routes, queries, forms, permissions, and UI state. Here&apos;s the React structure I use when a TanStack app grows past the simple stage without turning FSD into folder theater.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-code-structure/cover.png" alt="Code Structure in React: The FSD Version I Actually Use" /></p>
<p>At the insurance company, our React app started with a structure I still like for small projects.</p>
<pre><code>src/
  api/
  components/
  hooks/
  layouts/
  pages/
  types/
  router.ts
  main.tsx
</code></pre>
<p>For the first few months, it worked. A policy list page lived in <code>pages/</code>. Shared buttons lived in <code>components/</code>. Fetch functions lived in <code>api/</code>. Everyone understood where things went.</p>
<p>Then the policy builder arrived.</p>
<p>It was not a page anymore. It was a product inside the product: dynamic fields, conditional sections, autosave, permissions, validation, optimistic updates, draft recovery, and analytics. Suddenly one change touched <code>pages/</code>, <code>api/</code>, <code>types/</code>, <code>hooks/</code>, <code>components/</code>, and a couple of files nobody remembered creating.</p>
<p>I&#39;ve seen this pattern across multiple teams. A flat structure doesn&#39;t fail all at once. It fails quietly. First you add one more shared hook. Then one more domain type. Then a generic component that is not generic at all, just shared too early. Six months later the folders are still clean, but every feature is smeared across the project.</p>
<p>That is usually the moment people reach for Feature-Sliced Design.</p>
<p>Honestly, I think that instinct is right. FSD gives React projects a language for scale: layers, slices, public APIs, dependency direction. But if you apply it mechanically, it creates a different problem. The code becomes &quot;architecturally correct&quot; and painful to work with.</p>
<p>This article is the version I actually use in production React apps. Not pure FSD. Not flat folders forever. A practical structure for TanStack Query, TanStack Router, TanStack Form, and the kind of domain logic that appears in real applications.</p>
<blockquote>
<p>Architecture should make the next change easier to locate, not just easier to categorize.</p>
</blockquote>
<h2>Start Flat, Then Move When Pain Appears</h2>
<p>I don&#39;t start every React app with full FSD.</p>
<p>For a new Vite + TanStack project, this is still a good beginning:</p>
<pre><code>src/
  api/
    policies.ts
    query-keys.ts
  components/
    Button.tsx
    DataList.tsx
  layouts/
    RootLayout.tsx
  pages/
    PolicyListPage.tsx
    PolicyDetailPage.tsx
  types/
    policy.ts
  router.ts
  main.tsx
</code></pre>
<p>This structure is boring. Boring is good at the start.</p>
<p>The mistake is keeping it after the app stops being boring. You don&#39;t need FSD because the project has ten files. You need it when features become independent units of change.</p>
<p>The signal is not folder count. The signal is change pattern.</p>
<p>If every policy-related change touches five top-level folders, the structure is telling you something. If onboarding a developer requires explaining &quot;policy things are in several places, you&#39;ll get used to it,&quot; the structure is telling you something. If a file is in <code>shared/</code> because two components use it but both components belong to the same feature, the structure is telling you something.</p>
<p>At that point, I move to a sliced structure.</p>
<h2>The Structure</h2>
<p>The shape I use most often:</p>
<pre><code>src/
  app/
    router.ts
    providers.tsx
    query-client.ts
  pages/
    policy-list/
      PolicyListPage.tsx
      route.ts
    policy-detail/
      PolicyDetailPage.tsx
      route.ts
  widgets/
    policy-search-panel/
    policy-summary-card/
  features/
    create-policy/
    edit-policy-status/
    assign-policy-owner/
  entities/
    policy/
      api/
      model/
      ui/
      lib/
      index.ts
    customer/
      api/
      model/
      ui/
      index.ts
  shared/
    api/
    config/
    lib/
    ui/
</code></pre>
<p>The names come from FSD, but the goal is not to worship the names. The goal is to separate code by responsibility and change frequency.</p>
<p><strong><code>app/</code></strong> is application wiring. Providers, router creation, query client configuration, global error boundaries. It should know how the app starts. It should not know how policy validation works.</p>
<p><strong><code>pages/</code></strong> are route-level screens. They compose widgets, features, and entities. They should be thin. If a page file becomes a business logic container, something probably belongs lower.</p>
<p><strong><code>widgets/</code></strong> are larger UI blocks made from several domain pieces. A search panel, a dashboard section, a header with account controls.</p>
<p><strong><code>features/</code></strong> are user actions. Create policy. Update status. Assign owner. Submit payment method.</p>
<p><strong><code>entities/</code></strong> are domain concepts. Policy. Customer. Beneficiary. Claim. This is where domain types, query keys, API functions, selectors, and reusable entity UI usually live.</p>
<p><strong><code>shared/</code></strong> is for things that are truly domain-agnostic: buttons, date formatting, HTTP client, environment config, tiny utilities.</p>
<p>The import direction matters:</p>
<pre><code>app -&gt; pages -&gt; widgets -&gt; features -&gt; entities -&gt; shared
</code></pre>
<p>Lower layers don&#39;t import higher layers. <code>entities/policy</code> should not import from <code>features/create-policy</code>. <code>shared/ui/Button</code> should not know a policy exists.</p>
<p>That one rule prevents a lot of architectural rot.</p>
<h2>Where TanStack Query Goes</h2>
<p>The most common question in this stack: where do query hooks live?</p>
<p>My default: query keys and fetch functions live in the entity. Feature-specific mutations live in the feature if they represent a user action.</p>
<pre><code>src/
  entities/
    policy/
      api/
        policy.api.ts
        policy.queries.ts
      model/
        policy.types.ts
      index.ts
  features/
    edit-policy-status/
      api/
        edit-policy-status.mutation.ts
      ui/
        EditPolicyStatusButton.tsx
      index.ts
</code></pre>
<p>The entity owns the stable data contract:</p>
<pre><code class="language-ts">// src/entities/policy/api/policy.queries.ts
import { queryOptions } from &#39;@tanstack/react-query&#39;;
import { fetchPolicyById, fetchPolicies } from &#39;./policy.api&#39;;

export const policyKeys = {
  all: [&#39;policies&#39;] as const,
  lists: () =&gt; [...policyKeys.all, &#39;list&#39;] as const,
  list: (filters: PolicyListFilters) =&gt; [...policyKeys.lists(), filters] as const,
  details: () =&gt; [...policyKeys.all, &#39;detail&#39;] as const,
  detail: (policyId: string) =&gt; [...policyKeys.details(), policyId] as const,
};

export function policiesQueryOptions(filters: PolicyListFilters) {
  return queryOptions({
    queryKey: policyKeys.list(filters),
    queryFn: () =&gt; fetchPolicies(filters),
  });
}

export function policyDetailQueryOptions(policyId: string) {
  return queryOptions({
    queryKey: policyKeys.detail(policyId),
    queryFn: () =&gt; fetchPolicyById(policyId),
  });
}
</code></pre>
<p>The feature owns the action:</p>
<pre><code class="language-ts">// src/features/edit-policy-status/api/edit-policy-status.mutation.ts
import { useMutation, useQueryClient } from &#39;@tanstack/react-query&#39;;
import { policyKeys } from &#39;@/entities/policy&#39;;
import { updatePolicyStatus } from &#39;./edit-policy-status.api&#39;;

export function useEditPolicyStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updatePolicyStatus,
    onSuccess: (_, variables) =&gt; {
      queryClient.invalidateQueries({ queryKey: policyKeys.detail(variables.policyId) });
      queryClient.invalidateQueries({ queryKey: policyKeys.lists() });
    },
  });
}
</code></pre>
<p>This keeps the split honest. Reading policy data is part of the policy entity. Editing a status is a user action, so it belongs to a feature.</p>
<p>Could you put all mutations inside <code>entities/policy</code>? Sometimes, yes. For simple CRUD apps it works. But once mutations include permissions, analytics, form state, toasts, optimistic updates, or workflow-specific behavior, I prefer feature ownership. The code changes with the feature, so it should live with the feature.</p>
<h2>Where TanStack Router Goes</h2>
<p>TanStack Router adds an interesting pressure: route definitions are code, and code wants to be near the page it renders.</p>
<p>For small apps, one <code>src/router.ts</code> is fine. For larger apps, I split route pieces by page and compose them in <code>app/router.ts</code>.</p>
<pre><code>src/
  app/
    router.ts
  pages/
    policy-list/
      PolicyListPage.tsx
      policy-list.route.ts
    policy-detail/
      PolicyDetailPage.tsx
      policy-detail.route.ts
</code></pre>
<pre><code class="language-ts">// src/pages/policy-detail/policy-detail.route.ts
import { createRoute } from &#39;@tanstack/react-router&#39;;
import { rootRoute } from &#39;@/app/root-route&#39;;
import { policyDetailQueryOptions } from &#39;@/entities/policy&#39;;
import { PolicyDetailPage } from &#39;./PolicyDetailPage&#39;;

export const policyDetailRoute = createRoute({
  getParentRoute: () =&gt; rootRoute,
  path: &#39;/policies/$policyId&#39;,
  loader: ({ context, params }) =&gt;
    context.queryClient.ensureQueryData(policyDetailQueryOptions(params.policyId)),
  component: PolicyDetailPage,
});
</code></pre>
<p>The page route can import from entities because pages sit above entities. The entity must never import the route.</p>
<p>This also makes preloading natural. The route knows it needs a policy detail. The entity knows how to describe that query. TanStack Router and TanStack Query meet at the page boundary instead of leaking through the whole application.</p>
<h2>Where TanStack Form Goes</h2>
<p>Forms are where clean architecture usually goes to suffer.</p>
<p>A form is not just UI. It has schema, default values, validation, submission, dirty state, field components, and often domain-specific rules. If you split those across <code>shared/forms/</code>, <code>entities/</code>, <code>features/</code>, and <code>pages/</code> too early, every form change becomes a small hunt.</p>
<p>My rule:</p>
<blockquote>
<p>Put the form where the user action lives. Extract only the parts that are genuinely reused by another action.</p>
</blockquote>
<p>For creating a policy:</p>
<pre><code>src/
  features/
    create-policy/
      model/
        create-policy.schema.ts
        create-policy.types.ts
      api/
        create-policy.mutation.ts
      ui/
        CreatePolicyForm.tsx
        CoverageSection.tsx
        BeneficiariesSection.tsx
      index.ts
</code></pre>
<p>The feature owns the form because the form exists to perform the feature.</p>
<p>If <code>CoverageSection</code> later appears in three different policy workflows, then move it. Not before. Premature sharing is one of the fastest ways to make React code harder to change.</p>
<p>This is especially important with TanStack Form because field-level logic tends to be close to business rules:</p>
<pre><code class="language-tsx">// src/features/create-policy/ui/CreatePolicyForm.tsx
import { useForm } from &#39;@tanstack/react-form&#39;;
import { createPolicySchema } from &#39;../model/create-policy.schema&#39;;
import { useCreatePolicy } from &#39;../api/create-policy.mutation&#39;;

export function CreatePolicyForm() {
  const createPolicy = useCreatePolicy();

  const form = useForm({
    defaultValues: {
      holderName: &#39;&#39;,
      coverageType: &#39;standard&#39;,
      beneficiaries: [],
    },
    validators: {
      onSubmit: createPolicySchema,
    },
    onSubmit: ({ value }) =&gt; createPolicy.mutate(value),
  });

  return (
    &lt;form
      onSubmit={(event) =&gt; {
        event.preventDefault();
        form.handleSubmit();
      }}
    &gt;
      {/* fields */}
    &lt;/form&gt;
  );
}
</code></pre>
<p>The schema, mutation, and UI are close because they change together. That is not messy. That is locality.</p>
<h2>What Belongs in Shared</h2>
<p><code>shared/</code> is the most abused folder in React architecture.</p>
<p>If a file feels &quot;not specific enough,&quot; people put it in shared. Then shared becomes the new junk drawer: domain helpers, half-generic hooks, components with policy-specific props, validation rules from one workflow, and API helpers that know too much.</p>
<p>I use a stricter test.</p>
<p>A file belongs in <code>shared/</code> only if it can be moved to a different product in the same company and still make sense.</p>
<p>Good candidates:</p>
<pre><code>shared/
  api/
    http-client.ts
  config/
    env.ts
  lib/
    format-date.ts
    assert-never.ts
  ui/
    Button.tsx
    Dialog.tsx
    TextField.tsx
</code></pre>
<p>Bad candidates:</p>
<pre><code>shared/
  hooks/
    usePolicyValidation.ts
  lib/
    calculateCoveragePremium.ts
  ui/
    PolicyStatusBadge.tsx
</code></pre>
<p>Those are not shared. They are policy code. Put them in <code>entities/policy</code> or in the feature that owns the workflow.</p>
<p>This one discipline keeps FSD useful. When shared stays small, the rest of the architecture stays readable.</p>
<h2>Public APIs: The Boring Part That Matters</h2>
<p>Every slice should expose a public API through <code>index.ts</code>.</p>
<pre><code>src/entities/policy/index.ts
</code></pre>
<pre><code class="language-ts">export type { Policy, PolicyDetail, PolicyStatus } from &#39;./model/policy.types&#39;;
export { policyKeys, policyDetailQueryOptions, policiesQueryOptions } from &#39;./api/policy.queries&#39;;
export { PolicyStatusBadge } from &#39;./ui/PolicyStatusBadge&#39;;
</code></pre>
<p>Then other layers import from the slice root:</p>
<pre><code class="language-ts">import { PolicyStatusBadge, policyDetailQueryOptions } from &#39;@/entities/policy&#39;;
</code></pre>
<p>Not from internal paths:</p>
<pre><code class="language-ts">import { policyDetailQueryOptions } from &#39;@/entities/policy/api/policy.queries&#39;;
</code></pre>
<p>The public API gives you a boundary. You can reorganize internals without updating imports across the whole app. It also forces a small moment of intent: is this thing actually meant to be used outside the slice?</p>
<p>That question catches more design mistakes than people expect.</p>
<h2>The Rule I Break on Purpose</h2>
<p>Pure FSD can become too granular for real feature work. I break one rule deliberately: I colocate deeply related feature files even when they could be classified into separate layers.</p>
<p>If <code>CreatePolicyForm</code>, <code>createPolicySchema</code>, <code>createPolicyMutation</code>, and <code>CoverageSection</code> always change together, I keep them in <code>features/create-policy</code>.</p>
<p>I don&#39;t move <code>CoverageSection</code> to <code>widgets/</code> because it looks like a block.</p>
<p>I don&#39;t move <code>createPolicySchema</code> to <code>entities/policy</code> because it mentions policy fields.</p>
<p>I don&#39;t move a tiny helper to <code>shared/lib</code> because two files in the same feature use it.</p>
<p>This is not laziness. It is a design choice.</p>
<p>I&#39;ve been in the room where a team spent twenty minutes arguing whether a file was a feature, an entity, or a widget. Different company, different stack, same argument. At some point, the question stops being architectural and starts being clerical.</p>
<p>When that happens, I ask a simpler question: <strong>what changes with this file?</strong></p>
<p>If the answer is &quot;this feature,&quot; it stays in the feature.</p>
<h2>A Migration Path That Doesn&#39;t Hurt</h2>
<p>You don&#39;t need to restructure the entire app in one PR.</p>
<p>Start with one domain that already hurts. In the insurance CRM, that would have been policies.</p>
<p>Before:</p>
<pre><code>src/
  api/policies.ts
  components/PolicyStatusBadge.tsx
  hooks/usePolicyFilters.ts
  pages/PolicyListPage.tsx
  pages/PolicyDetailPage.tsx
  types/policy.ts
</code></pre>
<p>After:</p>
<pre><code>src/
  entities/
    policy/
      api/
        policy.api.ts
        policy.queries.ts
      model/
        policy.types.ts
      ui/
        PolicyStatusBadge.tsx
      index.ts
  pages/
    policy-list/
      PolicyListPage.tsx
    policy-detail/
      PolicyDetailPage.tsx
</code></pre>
<p>Then move the next workflow into <code>features/</code> when you touch it for real work.</p>
<p>Don&#39;t create empty folders for future architecture. Empty architecture is theater. Let structure follow actual code.</p>
<h2>How I Know the Structure Is Working</h2>
<p>Good structure has practical symptoms.</p>
<p>A new feature starts in one folder. A domain type is easy to find. Query invalidation is predictable. Shared stays boring. A page reads like composition instead of orchestration.</p>
<p>More importantly, changes have shape.</p>
<p>When a status-editing bug appears, you know to open <code>features/edit-policy-status</code>. When a policy response changes, you know to open <code>entities/policy</code>. When route preloading fails, you know to look at the page route.</p>
<p>That is the payoff. Not prettier folders. Faster orientation.</p>
<h2>What Comes Next</h2>
<p>The first three articles now give us the foundation:</p>
<ul>
<li><strong>Article #1:</strong> <a href="https://ma-x.im/blog/react-playbook-starting-new-project">the starting stack</a> — Vite, TanStack Query, Router, Form, and the minimal setup</li>
<li><strong>Article #2:</strong> <a href="https://ma-x.im/blog/react-playbook-typescript-patterns">TypeScript patterns</a> — typed API boundaries, query keys, route params, async state</li>
<li><strong>Article #3:</strong> this structure — how those pieces live together once the app grows</li>
</ul>
<p><strong>Article #4:</strong> <a href="https://ma-x.im/blog/react-playbook-error-handling">error handling</a> — Error Boundaries, TanStack Query error states, route-level failures, and what to show users when the happy path breaks.</p>
<hr>
<p>That policy builder eventually became one of the busiest parts of the insurance CRM. The structure was not perfect. No structure is. But after we moved policy code into a domain slice and kept workflow-specific logic inside features, the app became easier to reason about.</p>
<p>Not because FSD solved architecture for us. Because it gave us enough structure to stop guessing, and enough flexibility to keep related code close.</p>
<p>That&#39;s the version I trust: architecture as a working agreement, not a folder ceremony.</p>
<p>If you&#39;re trying to move a React codebase from flat folders into something more deliberate, feel free to reach out. The hard part is rarely naming the folders. The hard part is deciding what should change together.</p>
]]></content:encoded>
      <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Architecture</category>
      <category>FSD</category>
      <category>TanStack</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>TypeScript in React: Patterns That Actually Matter</title>
      <link>https://ma-x.im/blog/react-playbook-typescript-patterns</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-typescript-patterns</guid>
      <description>Not a TypeScript tutorial. A focused look at the patterns that eliminate real production bugs in React + TanStack apps — typed API layers, discriminated unions for async state, route param inference, and generic components done right.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-typescript-patterns/cover.png" alt="TypeScript in React: Patterns That Actually Matter" /></p>
<p>Six weeks into the insurance CRM project, we had a production incident. The backend team renamed a field — <code>policy_number</code> became <code>policyNumber</code> — as part of a snake_case-to-camelCase API migration. The migration happened on a Friday. By Monday morning, the policy list was blank for every user.</p>
<p>The bug took four minutes to find. The cause took longer to accept: we had typed the API response as <code>any</code>. Not carelessly — the original developer had written a comment: <em>&quot;TODO: add proper types.&quot;</em> The TODO was six months old.</p>
<p>Honest answer: I&#39;ve seen this exact scenario more times than I want to count. TypeScript installed, TypeScript ignored at the boundaries. <code>any</code> at the API layer, loose props in the middle, and full type safety only in the parts that didn&#39;t need it — utility functions, helpers, things that already worked.</p>
<p>This article is not a TypeScript tutorial. If you need to understand generics from first principles, there are better resources for that. What I want to cover are the specific patterns that prevent real production bugs in React + TanStack apps. The ones I reach for on every project now, not as best practices, but because I&#39;ve seen what happens without them.</p>
<h2>Pattern 1: The Typed API Layer</h2>
<p>The most important rule: <strong>type your API responses at the boundary, once.</strong></p>
<p>Not in the component. Not in the hook. At the fetch function that talks to the API. Everything downstream inherits the types automatically.</p>
<pre><code class="language-ts">// src/api/policies.ts
import type { Policy, PolicyDetail } from &#39;../types/policy&#39;;

export async function fetchPolicies(): Promise&lt;Policy[]&gt; {
  const res = await fetch(&#39;/api/policies&#39;);
  if (!res.ok) throw new Error(`GET /api/policies failed: ${res.status}`);
  return res.json() as Promise&lt;Policy[]&gt;;
}

export async function fetchPolicyById(policyId: string): Promise&lt;PolicyDetail&gt; {
  const res = await fetch(`/api/policies/${policyId}`);
  if (!res.ok) throw new Error(`GET /api/policies/${policyId} failed: ${res.status}`);
  return res.json() as Promise&lt;PolicyDetail&gt;;
}
</code></pre>
<pre><code class="language-ts">// src/types/policy.ts
export interface Policy {
  id: string;
  policyNumber: string;
  holderName: string;
  status: &#39;active&#39; | &#39;expired&#39; | &#39;pending&#39;;
  effectiveDate: string;
}

export interface PolicyDetail extends Policy {
  coverageType: string;
  premiumAmount: number;
  beneficiaries: Beneficiary[];
}

export interface Beneficiary {
  id: string;
  name: string;
  relationship: string;
  percentage: number;
}
</code></pre>
<p>Now <code>useQuery({ queryFn: fetchPolicies })</code> returns <code>Policy[]</code> — typed, autocompleted, checked at every usage. When the backend changes <code>policyNumber</code> to something else, TypeScript tells you <em>before</em> production.</p>
<p>The <code>as Promise&lt;Policy[]&gt;</code> cast on <code>res.json()</code> is intentional. <code>res.json()</code> returns <code>Promise&lt;unknown&gt;</code> in recent TypeScript. We&#39;re making an explicit contract: <em>this fetch function guarantees this shape.</em> If you want runtime validation as well, add Zod:</p>
<pre><code class="language-ts">import { z } from &#39;zod&#39;;

const PolicySchema = z.object({
  id: z.string(),
  policyNumber: z.string(),
  holderName: z.string(),
  status: z.enum([&#39;active&#39;, &#39;expired&#39;, &#39;pending&#39;]),
  effectiveDate: z.string(),
});

export async function fetchPolicies(): Promise&lt;Policy[]&gt; {
  const res = await fetch(&#39;/api/policies&#39;);
  if (!res.ok) throw new Error(`GET /api/policies failed: ${res.status}`);
  const raw = await res.json();
  return z.array(PolicySchema).parse(raw);
}
</code></pre>
<p>With Zod, a field rename or unexpected shape becomes a runtime error with a descriptive message, not a blank screen with no useful stack trace. Article #4 covers error handling strategies — for now, know this option exists.</p>
<h2>Pattern 2: Query Key Factories</h2>
<p>Query keys in TanStack Query are arrays. They look harmless until you have fifty queries and someone types <code>[&#39;policy&#39;, policyId]</code> in one file and <code>[&#39;policies&#39;, id]</code> in another. Invalidation stops working silently.</p>
<p>The fix is a query key factory — one object that owns all keys for a domain:</p>
<pre><code class="language-ts">// src/api/query-keys.ts
export const policyKeys = {
  all: [&#39;policies&#39;] as const,
  lists: () =&gt; [...policyKeys.all, &#39;list&#39;] as const,
  list: (filters: PolicyListFilters) =&gt; [...policyKeys.lists(), filters] as const,
  details: () =&gt; [...policyKeys.all, &#39;detail&#39;] as const,
  detail: (id: string) =&gt; [...policyKeys.details(), id] as const,
};
</code></pre>
<p>Usage:</p>
<pre><code class="language-ts">// In PolicyListPage
useQuery({
  queryKey: policyKeys.list({ status: &#39;active&#39; }),
  queryFn: () =&gt; fetchPolicies({ status: &#39;active&#39; }),
});

// In mutation after update
queryClient.invalidateQueries({ queryKey: policyKeys.lists() });

// In PolicyDetailPage
useQuery({
  queryKey: policyKeys.detail(policyId),
  queryFn: () =&gt; fetchPolicyById(policyId),
});
</code></pre>
<p>The <code>as const</code> assertions make the arrays readonly tuples instead of <code>(string | PolicyListFilters)[]</code>, which means TypeScript knows the exact structure. When you call <code>invalidateQueries({ queryKey: policyKeys.lists() })</code>, it correctly invalidates all list queries regardless of their filter arguments, because the key hierarchy matches.</p>
<p>This is a pattern I keep coming back to. Centralizing keys also makes it trivial to audit which queries exist in an application — you read one file.</p>
<h2>Pattern 3: Discriminated Unions for Async State</h2>
<p>The classic approach to async state in React:</p>
<pre><code class="language-tsx">const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
</code></pre>
<p>This is three independent booleans representing one state machine. The problem: you can have <code>loading: true</code> and <code>data: something</code> at the same time. TypeScript can&#39;t tell you that&#39;s impossible. Your component has to handle combinations that shouldn&#39;t exist.</p>
<p>TanStack Query solves this with its own state machine, but when you need custom async state — or when you want to model UI states explicitly — discriminated unions are the right tool:</p>
<pre><code class="language-ts">// src/types/async-state.ts
export type AsyncState&lt;T&gt; =
  | { status: &#39;idle&#39; }
  | { status: &#39;loading&#39; }
  | { status: &#39;error&#39;; error: Error }
  | { status: &#39;success&#39;; data: T };
</code></pre>
<p>Usage in a component:</p>
<pre><code class="language-tsx">function PolicyDetail({ policyId }: { policyId: string }) {
  const query = useQuery({
    queryKey: policyKeys.detail(policyId),
    queryFn: () =&gt; fetchPolicyById(policyId),
  });

  if (query.isPending) return &lt;PolicyDetailSkeleton /&gt;;
  if (query.isError) return &lt;ErrorMessage error={query.error} /&gt;;

  // TypeScript now knows query.data is PolicyDetail — not PolicyDetail | undefined
  const policy = query.data;

  return (
    &lt;div&gt;
      &lt;h1&gt;{policy.policyNumber}&lt;/h1&gt;
      &lt;p&gt;{policy.holderName}&lt;/p&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>After the <code>isError</code> guard, TypeScript narrows <code>query.data</code> to the non-undefined type. This is TanStack Query&#39;s built-in discriminated union working correctly. The principle applies when you build your own async state machines: model them as discriminated unions from the start, and TypeScript narrows automatically when you check <code>status</code>.</p>
<blockquote>
<p>TypeScript&#39;s type narrowing is only as good as your state model. If your state model allows impossible combinations, no amount of type annotations fixes that.</p>
</blockquote>
<h2>Pattern 4: Typed Route Params with TanStack Router</h2>
<p>This is where TanStack Router pays for itself. In article #1, we set up the router with a <code>Register</code> declaration. Here&#39;s what that gives you:</p>
<pre><code class="language-tsx">// src/pages/PolicyDetailPage.tsx
import { useParams } from &#39;@tanstack/react-router&#39;;

export function PolicyDetailPage() {
  // policyId is string — typed against the route definition
  // TypeScript error if this route doesn&#39;t have $policyId
  const { policyId } = useParams({ from: &#39;/policies/$policyId&#39; });

  // ...
}
</code></pre>
<p>Compare to React Router:</p>
<pre><code class="language-tsx">// React Router — manual cast, no safety
const { policyId } = useParams&lt;{ policyId: string }&gt;();
</code></pre>
<p>The React Router version compiles even if the route doesn&#39;t exist. The TanStack Router version fails to compile if the route doesn&#39;t have <code>$policyId</code>. That&#39;s the difference between type annotations and type inference.</p>
<p>Search params work the same way, but you need to define a validator on the route:</p>
<pre><code class="language-ts">// src/router.ts
import { z } from &#39;zod&#39;;

const policyListSearchSchema = z.object({
  status: z.enum([&#39;active&#39;, &#39;expired&#39;, &#39;pending&#39;]).optional(),
  page: z.number().int().positive().optional().default(1),
});

const policyListRoute = createRoute({
  getParentRoute: () =&gt; rootRoute,
  path: &#39;/policies&#39;,
  validateSearch: policyListSearchSchema,
  component: PolicyListPage,
});
</code></pre>
<pre><code class="language-tsx">// In PolicyListPage
const { status, page } = useSearch({ from: &#39;/policies&#39; });
// status: &#39;active&#39; | &#39;expired&#39; | &#39;pending&#39; | undefined
// page: number (default 1)
</code></pre>
<p>Search params validated with Zod, typed at the component level, consistent across navigation. No <code>URLSearchParams</code> parsing. No manual type assertions. Navigating to <code>/policies?status=invalid</code> either throws at the router level or falls back to the default — you control the behavior.</p>
<h2>Pattern 5: Generic Data Components</h2>
<p>React components that render lists of things are everywhere. The naive approach writes a new component for every list. The slightly better approach passes <code>items</code> as <code>any[]</code>. The right approach uses generics:</p>
<pre><code class="language-tsx">// src/components/DataList.tsx
interface DataListProps&lt;T&gt; {
  items: T[];
  getKey: (item: T) =&gt; string;
  renderItem: (item: T) =&gt; React.ReactNode;
  emptyMessage?: string;
}

export function DataList&lt;T&gt;({
  items,
  getKey,
  renderItem,
  emptyMessage = &#39;No items found&#39;,
}: DataListProps&lt;T&gt;) {
  if (items.length === 0) {
    return &lt;p className=&quot;text-muted-foreground&quot;&gt;{emptyMessage}&lt;/p&gt;;
  }

  return (
    &lt;ul className=&quot;space-y-2&quot;&gt;
      {items.map((item) =&gt; (
        &lt;li key={getKey(item)}&gt;{renderItem(item)}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}
</code></pre>
<p>Usage:</p>
<pre><code class="language-tsx">&lt;DataList
  items={policies}
  getKey={(p) =&gt; p.id}
  renderItem={(p) =&gt; &lt;PolicyRow policy={p} /&gt;}
  emptyMessage=&quot;No policies match this filter&quot;
/&gt;
</code></pre>
<p>TypeScript infers <code>T</code> as <code>Policy</code> from the <code>items</code> prop. The <code>getKey</code> and <code>renderItem</code> functions are typed to <code>Policy</code> automatically. You can&#39;t pass a <code>Beneficiary</code> to <code>renderItem</code> by accident.</p>
<p>This pattern works for any container component: tables, grids, selects, autocompletes. Write it once, use it everywhere, get full type safety for free.</p>
<h2>Pattern 6: Typed Context</h2>
<p>React Context is commonly the place where types collapse to <code>any</code> or to <code>T | undefined</code> that requires a null check at every usage. A cleaner pattern:</p>
<pre><code class="language-ts">// src/context/auth-context.tsx
interface AuthContextValue {
  userId: string;
  role: &#39;admin&#39; | &#39;agent&#39; | &#39;viewer&#39;;
  logout: () =&gt; void;
}

const AuthContext = React.createContext&lt;AuthContextValue | null&gt;(null);

export function useAuth(): AuthContextValue {
  const ctx = React.useContext(AuthContext);
  if (!ctx) throw new Error(&#39;useAuth must be used within AuthProvider&#39;);
  return ctx;
}
</code></pre>
<p>The context itself accepts <code>null</code> (before the provider mounts). The <code>useAuth</code> hook throws if used outside the provider and returns the non-null type. Every component that calls <code>useAuth()</code> gets <code>AuthContextValue</code> without optional chaining. The invariant is checked once, at the hook boundary.</p>
<p>This is not a new pattern. It&#39;s in the React docs. But I keep seeing codebases where context values are <code>T | undefined</code> and components are littered with <code>auth?.userId ?? &#39;&#39;</code>. The throw-in-the-hook approach pushes the validation to the right place and cleans up every callsite.</p>
<h2>What to Carry Forward</h2>
<p>These six patterns cover the majority of TypeScript friction I encounter in React codebases:</p>
<ul>
<li><strong>Typed API layer</strong> — type once at the boundary, inherit everywhere</li>
<li><strong>Query key factories</strong> — centralize keys, prevent invalidation bugs</li>
<li><strong>Discriminated unions</strong> — model states correctly, let TypeScript narrow</li>
<li><strong>TanStack Router inference</strong> — route params typed against actual routes, not manual casts</li>
<li><strong>Generic components</strong> — one implementation, full type safety across all usages</li>
<li><strong>Typed context with throwing hooks</strong> — clean callsites, invariant checked once</li>
</ul>
<p>None of these require advanced TypeScript knowledge. They require the discipline to type at boundaries instead of in the middle, and to model state as what it actually is rather than what&#39;s convenient.</p>
<hr>
<p>Six months after that production incident, the insurance CRM had a typed API layer across every endpoint and Zod validation on the most critical ones. We had two more backend field renames during that time. Neither reached production. TypeScript caught both during development, before the PRs were even reviewed.</p>
<p>That&#39;s the whole point — not type coverage as a metric, but fewer incidents as an outcome.</p>
<p>If you&#39;re working through TypeScript patterns in a React codebase and hitting specific friction points — reach out. These problems are usually more mechanical than they appear.</p>
]]></content:encoded>
      <pubDate>Sat, 07 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>TypeScript</category>
      <category>TanStack</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Designing Project Documentation for AI Coding Agents</title>
      <link>https://ma-x.im/blog/ai-agent-project-context</link>
      <guid isPermaLink="true">https://ma-x.im/blog/ai-agent-project-context</guid>
      <description>How structured documentation dramatically improves AI-generated code quality by giving agents the context they need.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/ai-agent-project-context/cover.png" alt="Designing Project Documentation for AI Coding Agents" /></p>
<p>I joined a project where everyone was writing code. Not just developers — project managers, analysts, even a designer who had never seen a terminal in his life. They would open VS Code, fire up Google&#39;s Gemini, and ask it to move a button, change a color, add border radius, tweak spacing. And the agent would do it. The code would work. And the codebase would get a little worse every time.</p>
<p>Because the agent had no context. It didn&#39;t know the project used a design system. It didn&#39;t know there were shared components for exactly these things. It didn&#39;t know that some files used CSS modules, others used inline styles, and a third group used something else entirely. So every AI-generated change was technically correct and architecturally wrong. The codebase turned into a patchwork of conflicting approaches.</p>
<p>I spent almost two weeks writing structured project documentation specifically for the agent. Architecture, component rules, naming conventions, styling approach, the whole picture. And then I ran my own test: I told the agent, &quot;Find 20 components that have something to fix.&quot; That&#39;s it. A dead simple, almost primitive prompt. The agent — now armed with full project context — went through the codebase, found real issues (wrong styling approach, missing design tokens, inconsistent patterns), and fixed them on the spot. It was the moment I realized: the model was never the problem. The missing context was.</p>
<h2>Context</h2>
<p>Most teams treat AI coding agents like extremely smart autocomplete. The agent sees the open files, maybe a README, and a prompt like &quot;implement this feature&quot;.</p>
<p>That&#39;s rarely enough.</p>
<p>In real projects, the missing context is huge:</p>
<ul>
<li>Why the product exists</li>
<li>Who the users are</li>
<li>Architectural constraints</li>
<li>Coding standards</li>
<li>Testing expectations</li>
<li>Future migration plans</li>
</ul>
<p>Humans fill these gaps from experience and project history. Agents don&#39;t.</p>
<p>While working on enterprise projects (including work done for large companies such as Procter &amp; Gamble and MedMe), I noticed the same pattern: agents generate better code when the project explicitly describes itself.</p>
<p>The solution that emerged was surprisingly simple.</p>
<p>Create a dedicated documentation layer designed for AI agents.</p>
<p>Not a README.
Not a wiki.</p>
<p>A structured project context directory.</p>
<h2>Core Concept</h2>
<p>The core idea is straightforward:</p>
<p>AI agents need explicit, structured context about the project.</p>
<p>Instead of relying on scattered documentation, the project exposes its knowledge in a predictable location.</p>
<p>Example:</p>
<pre><code>/docs
  product.md
  architecture.md
  coding-standards.md
  testing-strategy.md
  code-review.md
  design-system.md
  roadmap.md
  third-party-services.md
</code></pre>
<p>An index file explains what each document contains and when an agent should read it.</p>
<p>Example:</p>
<pre><code>docs/index.md

product.md
Short overview of the product: users, business goals, main workflows.

architecture.md
High-level architecture of the frontend and backend systems.

coding-standards.md
Project conventions and linting rules the agent must follow.

testing-strategy.md
What must be tested and how tests should be written.
</code></pre>
<p>Then the main instruction file references this folder.</p>
<p>Example:</p>
<pre><code>AGENT.md

When working on this project you MUST read the docs folder.

Start with:
docs/index.md

Based on the task you may need to read:
- architecture.md
- coding-standards.md
- testing-strategy.md
- design-system.md
</code></pre>
<p>This approach turns documentation into something closer to a context API for agents.</p>
<h2>Implementation Example</h2>
<p>A simplified instruction file for an agent might look like this:</p>
<pre><code class="language-md"># AGENT.md

This repository contains a React + Next.js application.

Before implementing any feature:
1. Read docs/index.md
2. Identify which documents are relevant for the task
3. Follow project conventions strictly

Key constraints:

- Use TypeScript strict mode
- Follow design tokens defined in docs/design-system.md
- All components must have tests (see docs/testing-strategy.md)
- Follow code review rules defined in docs/code-review.md
</code></pre>
<p>Example of coding standards exposed to the agent:</p>
<pre><code class="language-md"># coding-standards.md

Component rules:

- Components must be colocated with tests
- Use function components only
- Avoid default exports

Folder structure:

components/
  Button/
    Button.tsx
    Button.test.tsx
    Button.styles.ts
</code></pre>
<p>Example of testing expectations:</p>
<pre><code class="language-md"># testing-strategy.md

All UI components must include:

- unit tests (Vitest)
- interaction tests
- accessibility checks where applicable

Example:

describe(&quot;Button&quot;, () =&gt; {
  it(&quot;renders label&quot;, () =&gt; {})
  it(&quot;triggers click handler&quot;, () =&gt; {})
})
</code></pre>
<p>Once this context exists, agents consistently generate code that is much closer to what a real team would accept.</p>
<h2>Integrating Task Context via MCP</h2>
<p>Documentation alone is useful, but it becomes much more powerful when combined with external context.</p>
<p>Using MCP integrations, an agent can retrieve additional project history:</p>
<ul>
<li>Jira tasks</li>
<li>Git commits</li>
<li>bug reports</li>
<li>previous discussions</li>
</ul>
<p>Example workflow:</p>
<ol>
<li>Agent works on a component</li>
<li>It inspects commit history</li>
<li>Extracts issue IDs</li>
<li>Queries Jira via MCP</li>
<li>Reads bug descriptions</li>
</ol>
<p>This allows the agent to understand things like:</p>
<ul>
<li>why a component was implemented in a specific way</li>
<li>what bugs previously occurred</li>
<li>which constraints must not be broken</li>
</ul>
<p>Without this, agents tend to unknowingly reintroduce old bugs.</p>
<h2>Where It Breaks in Enterprise</h2>
<p>This approach works well, but it introduces new challenges.</p>
<h3>Documentation drift</h3>
<p>Docs become outdated quickly if they are not maintained.</p>
<p>Agents will follow outdated rules very confidently.</p>
<h3>Over-documentation</h3>
<p>Too many documents create the opposite problem: agents get lost in irrelevant context.</p>
<p>Keep documentation focused and scoped.</p>
<h3>Missing architectural intent</h3>
<p>If architecture documents only describe current implementation but not intent, agents will optimize locally and break long-term plans.</p>
<p>For example:</p>
<ul>
<li>migrating from Redux to TanStack Query</li>
<li>replacing a design system</li>
<li>upgrading React versions</li>
</ul>
<p>Agents need to know about these plans.</p>
<h2>Common Mistakes</h2>
<h3>Treating README as agent documentation</h3>
<p>README files are written for humans. They are narrative and incomplete.</p>
<p>Agents work better with:</p>
<ul>
<li>short</li>
<li>structured</li>
<li>explicit documentation.</li>
</ul>
<h3>Documenting only code structure</h3>
<p>Agents also need business context.</p>
<p>Example:</p>
<pre><code>Users: pharmacists
Main workflow: reviewing prescriptions
Critical requirement: audit traceability
</code></pre>
<p>Without this, agents optimize code but miss product constraints.</p>
<h3>Not documenting code review expectations</h3>
<p>Agents can generate correct code that still fails review.</p>
<p>For example:</p>
<ul>
<li>missing tests</li>
<li>wrong folder structure</li>
<li>inconsistent naming</li>
</ul>
<p>Explicit review rules prevent this.</p>
<h2>When NOT To Use This</h2>
<p>This approach is unnecessary for:</p>
<ul>
<li>very small repositories</li>
<li>short-lived prototypes</li>
<li>single-developer experiments</li>
</ul>
<p>The overhead only pays off when:</p>
<ul>
<li>multiple developers collaborate</li>
<li>AI agents are heavily used</li>
<li>the project has architectural complexity.</li>
</ul>
<h2>How I Apply This in Real Projects</h2>
<p>In projects where I use AI coding agents (primarily Claude-based tools, GPT assistants, and Gemini), I always start by creating a docs directory designed for the agent.</p>
<p>The process is simple:</p>
<ol>
<li>Write a short product overview</li>
<li>Describe architecture at a high level</li>
<li>Define coding and testing rules</li>
<li>Document code review expectations</li>
<li>Outline future architectural plans</li>
</ol>
<p>The two-week documentation effort I described at the beginning of this article wasn&#39;t typical — that was a rescue operation for a codebase that had already accumulated months of context-free AI changes. For a new project, the initial setup takes a day or two. After that, you maintain it incrementally.</p>
<p>The &quot;find 20 components to fix&quot; trick has become my standard smoke test for new documentation. If the agent can autonomously identify and fix real issues based on the context you&#39;ve written, the documentation is working. If it makes the same mistakes as before, the docs are missing something important.</p>
<h2>Practical Recommendations</h2>
<p>If you want to try this approach:</p>
<p>Start small.</p>
<p>Create just four documents:</p>
<pre><code>docs/product.md
docs/architecture.md
docs/coding-standards.md
docs/testing-strategy.md
</code></pre>
<p>Add an index file and reference it from your agent instructions.</p>
<p>Then gradually expand documentation as real problems appear.</p>
<p>Also remember:</p>
<ul>
<li>keep documents short</li>
<li>keep them structured</li>
<li>keep them updated.</li>
</ul>
<p>Think of them as system prompts for your codebase.</p>
<h2>Summary</h2>
<p>The project where non-developers were writing code through AI taught me something fundamental: models are already good enough. The bottleneck isn&#39;t intelligence — it&#39;s context. An agent without project documentation will confidently produce code that breaks every convention you have. An agent with structured context will find and fix problems you didn&#39;t even ask about.</p>
<p>A structured docs/ directory can describe:</p>
<ul>
<li>product context</li>
<li>architecture</li>
<li>coding standards</li>
<li>testing rules</li>
<li>code review expectations</li>
<li>future architectural plans</li>
</ul>
<p>Combined with integrations like Jira and Git history, this gives agents enough context to generate code that aligns with real production systems.</p>
<p>In practice, this simple change dramatically improves the quality of AI-generated code. Two weeks of documentation saved me months of cleanup.</p>
<hr>
<p>If you have feedback, alternative approaches, or just want to discuss this topic — feel free to reach out.</p>
]]></content:encoded>
      <pubDate>Fri, 06 Mar 2026 00:00:00 GMT</pubDate>
      <category>AI</category>
      <category>Documentation</category>
      <category>DX</category>
      <category>Architecture</category>
    </item>
    <item>
      <title>Starting a New React Project in 2026-2027: The Stack I Keep Coming Back To</title>
      <link>https://ma-x.im/blog/react-playbook-starting-new-project</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-playbook-starting-new-project</guid>
      <description>Every new React project starts with the same question: which tools? After years of watching teams assemble mismatched libraries that fight each other, I stopped choosing independently. This is the coherent stack I now use by default — and why.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-playbook-starting-new-project/cover.png" alt="Starting a New React Project in 2026-2027: The Stack I Keep Coming Back To" /></p>
<p>I was three weeks into a new client engagement — a mid-sized insurance company building a policy administration CRM from scratch. No legacy code, no inherited debt. Just a blank repo and a brief that said &quot;build it right this time.&quot;</p>
<p>The first real decision hit before I wrote a single component. Not React vs. something else — that was settled. The question was: <em>what handles routing? What handles server state? What handles forms? What handles client state?</em> Each one is a separate choice. Each one affects the others.</p>
<p>I&#39;ve been in that moment before. Different company, different domain, same paralysis. And I&#39;ve watched teams make the same mistake each time: they pick tools independently, without thinking about how they work together. React Router because someone knows it. Redux Toolkit because it was used at the last job. React Hook Form because of a tutorial someone bookmarked. Three months in, you have routing that&#39;s loosely typed, server state duplicated in Redux, and form validation that doesn&#39;t talk to your query layer. The tools work in isolation. The system doesn&#39;t.</p>
<p>That insurance CRM was where I fully committed to TanStack as an ecosystem. Not because it was new. Because it was the most coherent answer I&#39;d found to a question I&#39;d been asking for years: <em>how do these pieces actually fit together?</em></p>
<p>This is the first article in the <strong>React Playbook</strong> series — a hands-on guide to building production React apps with TanStack and a thoughtful architecture. We&#39;re starting here, at the beginning, because the choices you make in the first hour of a project shape everything that comes after.</p>
<h2>What TanStack Actually Is</h2>
<p>TanStack is a family of headless, framework-agnostic libraries maintained primarily by Tanner Linsley. The word <em>family</em> matters. These packages are designed to work independently — you can use TanStack Query with React Router, or TanStack Router with SWR — but they&#39;re built by the same team with consistent APIs, consistent TypeScript patterns, and a consistent philosophy about what a library should own vs. what your application should own.</p>
<p>The four packages that form the core of this series:</p>
<ul>
<li><strong>TanStack Query</strong> — async state. Fetching, caching, background refetches, mutations, optimistic updates. It doesn&#39;t care how you fetch; it cares about <em>what happens to the data after you fetch it</em>.</li>
<li><strong>TanStack Router</strong> — type-safe routing. Every route param, every search param, every navigate call is typed against your actual route tree. No <code>useParams&lt;{ id: string }&gt;()</code> manual type assertions.</li>
<li><strong>TanStack Form</strong> — form state management. Headless, composable, works with any validation library. Deep integration with Query mutations comes naturally.</li>
<li><strong>TanStack Start</strong> — full-stack framework built on top of TanStack Router. SSR, server functions, streaming. We&#39;ll cover this in article #15. For now, know it exists.</li>
</ul>
<p>What&#39;s not in TanStack: client state management — the kind of state that lives only in the browser and doesn&#39;t come from a server. For that, this series uses <strong>Reatom</strong>. I&#39;ll explain that choice properly in article #7. For now, note that <code>useState</code> and TanStack Query handle the vast majority of state in a typical app. Reatom enters the picture when you have genuinely complex reactive graphs.</p>
<blockquote>
<p>The best stack isn&#39;t the one with the individually best tools. It&#39;s the one where the tools share a language.</p>
</blockquote>
<h2>Two Ways to Start</h2>
<h3>Option 1: Vite (SPA)</h3>
<p>If you&#39;re building a client-rendered SPA — with a separate API, no SSR, no server functions — Vite is the starting point:</p>
<pre><code class="language-bash">npm create vite@latest my-crm-app -- --template react-ts
cd my-crm-app
npm install
</code></pre>
<p>Then add the TanStack core:</p>
<pre><code class="language-bash">npm install @tanstack/react-query @tanstack/react-router @tanstack/react-form
npm install -D @tanstack/router-devtools @tanstack/react-query-devtools
</code></pre>
<p>Add Zod for schema validation — it integrates directly with TanStack Form and pairs naturally with TypeScript:</p>
<pre><code class="language-bash">npm install zod
</code></pre>
<p>That&#39;s the complete starting dependency set. Not ten packages. Five.</p>
<h3>Option 2: TanStack Start (SSR / Full-stack)</h3>
<p>If you need SSR, server functions, or want to colocate data fetching with your routes:</p>
<pre><code class="language-bash">npx create-tsrouter-app@latest my-crm-app --template start-basic
cd my-crm-app
npm install
</code></pre>
<p>TanStack Start comes with React, TanStack Router, and Vinxi (the underlying build layer) preconfigured. You add TanStack Query and Form separately — same commands as above.</p>
<p>For the rest of this article — and the majority of this series — I&#39;ll use the Vite setup. We&#39;ll revisit TanStack Start in article #15 when we get to SSR and full-stack patterns.</p>
<h2>The Router Setup</h2>
<p>TanStack Router supports both file-based and code-based route definitions. I use code-based for new projects. It&#39;s more explicit, easier to understand when you&#39;re learning the ecosystem, and easier to refactor when your route tree changes.</p>
<p>Start by creating the route tree:</p>
<pre><code class="language-tsx">// src/router.ts
import { createRouter, createRoute, createRootRoute } from &#39;@tanstack/react-router&#39;;
import { RootLayout } from &#39;./layouts/RootLayout&#39;;
import { DashboardPage } from &#39;./pages/DashboardPage&#39;;
import { PolicyListPage } from &#39;./pages/PolicyListPage&#39;;
import { PolicyDetailPage } from &#39;./pages/PolicyDetailPage&#39;;

const rootRoute = createRootRoute({
  component: RootLayout,
});

const dashboardRoute = createRoute({
  getParentRoute: () =&gt; rootRoute,
  path: &#39;/&#39;,
  component: DashboardPage,
});

const policyListRoute = createRoute({
  getParentRoute: () =&gt; rootRoute,
  path: &#39;/policies&#39;,
  component: PolicyListPage,
});

const policyDetailRoute = createRoute({
  getParentRoute: () =&gt; rootRoute,
  path: &#39;/policies/$policyId&#39;,
  component: PolicyDetailPage,
});

const routeTree = rootRoute.addChildren([
  dashboardRoute,
  policyListRoute,
  policyDetailRoute,
]);

export const router = createRouter({ routeTree });

declare module &#39;@tanstack/react-router&#39; {
  interface Register {
    router: typeof router;
  }
}
</code></pre>
<p>The <code>Register</code> declaration at the bottom is what makes everything type-safe. From this point forward, every <code>useParams()</code>, every <code>&lt;Link to=&quot;&quot;&gt;</code>, every <code>navigate()</code> call is checked against your actual route tree. If you rename <code>$policyId</code> to <code>$id</code>, TypeScript will tell you every single broken reference in the codebase. That&#39;s not a nice-to-have at scale. That&#39;s essential.</p>
<h2>The App Entry Point</h2>
<pre><code class="language-tsx">// src/main.tsx
import React from &#39;react&#39;;
import ReactDOM from &#39;react-dom/client&#39;;
import { QueryClient, QueryClientProvider } from &#39;@tanstack/react-query&#39;;
import { RouterProvider } from &#39;@tanstack/react-router&#39;;
import { ReactQueryDevtools } from &#39;@tanstack/react-query-devtools&#39;;
import { router } from &#39;./router&#39;;

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 1,
    },
  },
});

ReactDOM.createRoot(document.getElementById(&#39;root&#39;)!).render(
  &lt;React.StrictMode&gt;
    &lt;QueryClientProvider client={queryClient}&gt;
      &lt;RouterProvider router={router} /&gt;
      &lt;ReactQueryDevtools initialIsOpen={false} /&gt;
    &lt;/QueryClientProvider&gt;
  &lt;/React.StrictMode&gt;,
);
</code></pre>
<p>Two decisions embedded here that are worth naming explicitly.</p>
<p><strong><code>staleTime: 1000 * 60 * 5</code></strong> — five minutes. By default, TanStack Query considers data stale immediately and refetches on every component mount and every window focus event. For a CRM where users navigate between the same policy records repeatedly, that&#39;s a lot of unnecessary network traffic. Five minutes as a global baseline is a reasonable starting point. You&#39;ll override it per-query when freshness matters more.</p>
<p><strong><code>retry: 1</code></strong> — retry failed requests once, not three times (the default). The default behavior means a failed API call spends up to 30 seconds retrying before your user sees an error. In a CRM, users notice latency. Fail fast, show the error, let the user decide whether to retry.</p>
<h2>Your First Query</h2>
<p>A policy list page. Here&#39;s what the data layer looks like end to end:</p>
<pre><code class="language-ts">// src/api/policies.ts
import type { Policy } from &#39;../types/policy&#39;;

export async function fetchPolicies(): Promise&lt;Policy[]&gt; {
  const res = await fetch(&#39;/api/policies&#39;);
  if (!res.ok) throw new Error(`Failed to fetch policies: ${res.status}`);
  return res.json();
}
</code></pre>
<pre><code class="language-tsx">// src/pages/PolicyListPage.tsx
import { useQuery } from &#39;@tanstack/react-query&#39;;
import { Link } from &#39;@tanstack/react-router&#39;;
import { fetchPolicies } from &#39;../api/policies&#39;;

export function PolicyListPage() {
  const { data: policies, isLoading, isError } = useQuery({
    queryKey: [&#39;policies&#39;],
    queryFn: fetchPolicies,
  });

  if (isLoading) return &lt;div&gt;Loading policies...&lt;/div&gt;;
  if (isError) return &lt;div&gt;Failed to load policies&lt;/div&gt;;

  return (
    &lt;ul&gt;
      {policies.map((policy) =&gt; (
        &lt;li key={policy.id}&gt;
          &lt;Link to=&quot;/policies/$policyId&quot; params={{ policyId: policy.id }}&gt;
            {policy.policyNumber}
          &lt;/Link&gt;
        &lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}
</code></pre>
<p>A few things worth making explicit.</p>
<p><strong><code>queryKey: [&#39;policies&#39;]</code></strong> — this is the identity of the data. TanStack Query caches, deduplicates, and invalidates data by key. When you update a policy, you&#39;ll call <code>queryClient.invalidateQueries({ queryKey: [&#39;policies&#39;] })</code> and this list will automatically refetch. The key design decision: keep keys predictable and structured. We&#39;ll go deep on key patterns in article #5.</p>
<p><strong><code>&lt;Link to=&quot;/policies/$policyId&quot; params={{ policyId: policy.id }}&gt;</code></strong> — fully type-checked. The <code>to</code> prop only accepts valid routes from your route tree. The <code>params</code> prop is typed to exactly what that route expects. You cannot pass a typo here and have it compile.</p>
<h2>What You Don&#39;t Need Yet</h2>
<p>I keep coming back to this piece of advice because I&#39;ve seen it ignored repeatedly on greenfield projects.</p>
<p>Do not install:</p>
<ul>
<li>An i18n library until you actually have a second language requirement</li>
<li>A component library until you understand your design system constraints</li>
<li>A global state manager beyond <code>useState</code> until Query&#39;s cache isn&#39;t enough</li>
<li>A charting library until you&#39;re building a chart</li>
<li>An analytics package until the product is real</li>
</ul>
<p>Every package you add before you need it is a decision made without enough information. The &quot;complete stack&quot; instinct — wanting to assemble everything before writing features — consistently results in wrong choices that are expensive to undo.</p>
<p>Honestly, the most common refactor I do on client codebases is removing things. Unused dependencies, abstractions that outlived their problems, config for features that were never built.</p>
<p>Start with the minimum. Add what you need when you need it.</p>
<h2>Project Structure</h2>
<p>Article #3 covers FSD (Feature-Sliced Design) properly, so I won&#39;t go deep here. But the starting skeleton that works for the first 20-30 features of any project:</p>
<pre><code>src/
  api/            ← fetch functions, typed response shapes
  pages/          ← page-level components wired to routes
  layouts/        ← RootLayout, AuthedLayout, etc.
  components/     ← shared, reusable UI
  hooks/          ← shared React hooks
  types/          ← TypeScript interfaces and domain types
  router.ts       ← route tree definition
  main.tsx        ← app entry point
</code></pre>
<p>This structure is deliberately flat. It scales to about 25-30 features before it starts feeling crowded. At that point, you&#39;ll naturally feel the pull toward domain-based organization — and that&#39;s when FSD becomes the right tool. We&#39;ll get there.</p>
<h2>Running It</h2>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>You&#39;ll see two devtools panels appear: the TanStack Router devtools in the bottom corner and the React Query devtools floating panel. Both are worth learning. Router devtools shows your matched route, route params, and pending navigations. Query devtools shows every active query, its status, its data, its stale time, and lets you manually invalidate or refetch. Remove them for production by checking <code>import.meta.env.DEV</code>.</p>
<h2>What Comes Next</h2>
<p>This setup — Vite + TanStack Query + TanStack Router — is the foundation everything else in this series builds on. Over the next 29 articles:</p>
<ul>
<li><strong>Article #2:</strong> <a href="https://ma-x.im/blog/react-playbook-typescript-patterns">TypeScript patterns specific to this stack</a> — generics in query hooks, typed API layers, discriminated unions for UI states</li>
<li><strong>Article #3:</strong> <a href="https://ma-x.im/blog/react-playbook-code-structure">FSD architecture</a> — when the flat <code>pages/</code> structure starts hurting and what replaces it</li>
<li><strong>Article #4:</strong> Error handling — Error Boundaries, query error states, global error strategies</li>
<li><strong>Article #7:</strong> Reatom — when your client state grows beyond what <code>useState</code> handles cleanly</li>
</ul>
<p>The same QueryClient, the same router, the same project structure — growing incrementally instead of being replaced. That&#39;s the goal.</p>
<hr>
<p>That insurance CRM shipped twelve months later. Four developers, significant business logic, a forms-heavy UI with dozens of policy types. The TanStack ecosystem handled all of it without once making us fight our own tools. That&#39;s the standard I hold stacks to: not &quot;is it powerful,&quot; but &quot;does it get out of the way.&quot;</p>
<p>If you&#39;re starting a new React project and want to talk through stack choices before committing — feel free to reach out. I&#39;ve had this conversation more times than I can count and it rarely takes as long as people expect.</p>
]]></content:encoded>
      <pubDate>Tue, 03 Mar 2026 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>TanStack</category>
      <category>TypeScript</category>
      <category>React Playbook</category>
    </item>
    <item>
      <title>Next.js Static Export to GitHub Pages: A Production Setup Guide</title>
      <link>https://ma-x.im/blog/nextjs-ssg-github-pages</link>
      <guid isPermaLink="true">https://ma-x.im/blog/nextjs-ssg-github-pages</guid>
      <description>How to configure Next.js 15 static export and deploy to GitHub Pages with a custom domain. Covers App Router, basePath, react-markdown blog pipeline, GitHub Actions CI/CD, and common pitfalls.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/nextjs-ssg-github-pages/cover.png" alt="Next.js Static Export to GitHub Pages: A Production Setup Guide" /></p>
<p>Static export remains the simplest deployment model for content-focused sites. This article documents the setup behind this very site — ma-x.im — a Next.js 15 project deployed to GitHub Pages.</p>
<p>Honestly, this was my first static export project. Vercel exists and it&#39;s free too. But I wanted something dead simple — no platform dependencies, no vendor lock-in, just push and it&#39;s live. GitHub Pages felt like the most boring choice possible, and that was exactly the point. A few weeks in, I&#39;m genuinely happy with it. I don&#39;t think about deployments, I don&#39;t debug infrastructure, it just works. Sometimes the boring choice is the right one.</p>
<h2>Context</h2>
<p>The goal: a personal site with static content and a blog, deployed to GitHub Pages with a custom domain. Requirements are low ops overhead, no monthly costs, and good reliability. GitHub Pages handles all of this well for static content.</p>
<p>GitHub Pages constraints shape the architecture:</p>
<p><strong>Static hosting only.</strong> No server-side rendering, no API routes that execute at runtime, no middleware. Everything must be pre-rendered at build time.</p>
<p><strong>Project Page path prefix.</strong> If you deploy to <code>https://&lt;user&gt;.github.io/&lt;repo&gt;/</code>, all routes are prefixed with <code>/&lt;repo&gt;/</code>. This breaks absolute paths and requires explicit configuration. With a custom domain, this prefix disappears — the site serves at root.</p>
<p><strong>No redirects or rewrites.</strong> GitHub Pages serves files as-is. You cannot configure server-side redirects. Client-side routing works, but direct navigation to a non-existent file returns GitHub&#39;s 404 page unless you provide a <code>404.html</code>.</p>
<p><strong>404 handling.</strong> GitHub Pages serves <code>404.html</code> from the root when a file isn&#39;t found. Next.js static export generates this file from your <code>not-found.tsx</code>, but only if you have one.</p>
<h2>Core Concept</h2>
<h3>Next.js SSG vs SSR/ISR</h3>
<p>Next.js supports multiple rendering strategies. For GitHub Pages, only Static Site Generation (SSG) works:</p>
<ul>
<li><strong>SSR (Server-Side Rendering):</strong> Renders pages on each request. Requires a Node.js server. Not compatible with static hosting.</li>
<li><strong>ISR (Incremental Static Regeneration):</strong> Pre-renders at build, then revalidates on a schedule. Requires a server. Not compatible with static hosting.</li>
<li><strong>SSG (Static Site Generation):</strong> Pre-renders all pages at build time. Outputs plain HTML/CSS/JS files. Works on any static host.</li>
</ul>
<h3>Static Export in Next.js 15</h3>
<p>In Next.js 13+, static export is enabled via <code>output: &#39;export&#39;</code> in your config. The build process generates a folder (default: <code>out/</code>) containing all HTML files, assets, and client-side JavaScript bundles.</p>
<p>This mode disables features that require a server runtime:</p>
<ul>
<li>API routes (unless they&#39;re static JSON files)</li>
<li>Server Actions</li>
<li>Middleware</li>
<li>Dynamic rendering with <code>cookies()</code> or <code>headers()</code></li>
<li>Redirects and rewrites in <code>next.config.ts</code></li>
<li>Image Optimization API (requires a server)</li>
</ul>
<h3>basePath for Project Pages</h3>
<p>When deploying to <code>https://&lt;user&gt;.github.io/&lt;repo&gt;/</code>, the site lives at a subpath, not root. Without configuration, asset URLs and links break:</p>
<pre><code>Expected: /styles/main.css
Actual location: /&lt;repo&gt;/styles/main.css
→ 404
</code></pre>
<p>The <code>basePath</code> config prepends the prefix to all routes and asset imports:</p>
<pre><code class="language-typescript">// next.config.ts
const nextConfig: NextConfig = {
  basePath: &#39;/my-repo&#39;,
  output: &#39;export&#39;,
};
</code></pre>
<p>Next.js automatically handles:</p>
<ul>
<li><code>&lt;Link href=&quot;/about&quot;&gt;</code> becomes <code>/&lt;repo&gt;/about</code></li>
<li>Static imports and public folder assets</li>
<li>CSS/JS bundle paths</li>
</ul>
<p><strong>With a custom domain, basePath should not be set.</strong> The domain points directly to the repo&#39;s Pages site, so routes serve at root.</p>
<h3>trailingSlash</h3>
<p>Controls whether routes end with <code>/</code>:</p>
<ul>
<li><code>trailingSlash: false</code> → <code>/blog/my-post</code> (outputs <code>/blog/my-post.html</code>)</li>
<li><code>trailingSlash: true</code> → <code>/blog/my-post/</code> (outputs <code>/blog/my-post/index.html</code>)</li>
</ul>
<p>GitHub Pages handles both, but <code>trailingSlash: true</code> is more predictable for some edge cases with direct file navigation. The trade-off is URL aesthetics. This repo uses <code>false</code>.</p>
<h3>assetPrefix</h3>
<p>Separate from <code>basePath</code>, <code>assetPrefix</code> controls where JS/CSS bundles are loaded from. Useful when assets are served from a CDN. For most GitHub Pages deployments, it&#39;s not needed — <code>basePath</code> handles everything.</p>
<h3>next/image on Static Hosts</h3>
<p>The default <code>next/image</code> component uses Next.js Image Optimization API, which requires a server. On static export, you must either:</p>
<ol>
<li>Set <code>images: { unoptimized: true }</code> to disable optimization and use raw image URLs</li>
<li>Use a third-party loader (Cloudinary, Imgix, etc.)</li>
<li>Avoid <code>next/image</code> entirely and use <code>&lt;img&gt;</code> tags</li>
</ol>
<p>This repo uses native <code>&lt;img&gt;</code> tags with a custom fallback wrapper, avoiding the issue entirely.</p>
<h2>Implementation Example</h2>
<h3>1) Configure Next.js for static export (SSG)</h3>
<p>The current <code>next.config.ts</code> in this repo:</p>
<pre><code class="language-typescript">import type { NextConfig } from &#39;next&#39;;

const nextConfig: NextConfig = {
  output: &#39;export&#39;,
  images: {
    unoptimized: true,
  },
  trailingSlash: false,
};

export default nextConfig;
</code></pre>
<p>This configuration:</p>
<ul>
<li>Enables static export to <code>out/</code> directory</li>
<li>Disables image optimization (required for static hosting)</li>
<li>Uses clean URLs without trailing slashes</li>
</ul>
<p><strong>For a Project Page deployment</strong> (without custom domain), add <code>basePath</code>:</p>
<pre><code class="language-typescript">// Proposed change for Project Page deployment
const nextConfig: NextConfig = {
  output: &#39;export&#39;,
  basePath: &#39;/ma-x.im&#39;, // Repository name
  images: {
    unoptimized: true,
  },
  trailingSlash: false,
};
</code></pre>
<p><strong>Limitations to understand:</strong></p>
<p>With <code>output: &#39;export&#39;</code>, these features throw build errors or are ignored:</p>
<ul>
<li><code>redirects</code> and <code>rewrites</code> in config</li>
<li>Middleware (<code>middleware.ts</code>)</li>
<li>API routes with runtime logic</li>
<li>Server Actions</li>
<li><code>cookies()</code>, <code>headers()</code>, <code>searchParams</code> in Server Components (without <code>force-static</code>)</li>
</ul>
<h3>2) Make routing and links survive basePath (Project Page)</h3>
<p><strong>The failure mode:</strong> During development, the site runs at <code>localhost:3000/</code>. Links like <code>&lt;Link href=&quot;/blog&quot;&gt;</code> work. After deploying to <code>https://user.github.io/repo/</code>, the same link points to <code>https://user.github.io/blog</code> (missing the repo prefix), returning 404.</p>
<p><strong>How Next.js handles it:</strong> When <code>basePath</code> is configured, <code>next/link</code> and <code>next/router</code> automatically prepend it. You don&#39;t need to change your code — just avoid hardcoded absolute paths.</p>
<p>From this repo&#39;s <code>src/app/blog/page.tsx</code>:</p>
<pre><code class="language-tsx">import Link from &#39;next/link&#39;;

// This works correctly with or without basePath
&lt;Link href={\`/blog/\${post.id}\`}&gt;
  &lt;h2&gt;{post.title}&lt;/h2&gt;
&lt;/Link&gt;
</code></pre>
<p><strong>What breaks:</strong></p>
<ol>
<li><strong>Hardcoded absolute paths in JavaScript/CSS:</strong></li>
</ol>
<pre><code class="language-tsx">// Bad: Ignores basePath
const logoUrl = &#39;/images/logo.png&#39;;

// Good: Use relative paths or public folder imports
import logo from &#39;@/public/images/logo.png&#39;;
</code></pre>
<ol start="2">
<li><strong>Metadata URLs (OpenGraph, Twitter, canonical):</strong></li>
</ol>
<p>From this repo&#39;s <code>src/app/blog/[slug]/page.tsx</code>:</p>
<pre><code class="language-tsx">export async function generateMetadata({ params }: BlogPostPageProps): Promise&lt;Metadata&gt; {
  const { slug } = await params;
  const post = blogPosts.find((p) =&gt; p.id === slug);

  return {
    openGraph: {
      url: \`https://ma-x.im/blog/\${post.id}\`, // Hardcoded full URL
    },
  };
}
</code></pre>
<p>This works because the site uses a custom domain at root. For a Project Page, you&#39;d need:</p>
<pre><code class="language-tsx">// Proposed change for Project Page
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || &#39;https://user.github.io/repo&#39;;

url: \`\${BASE_URL}/blog/\${post.id}\`,
</code></pre>
<ol start="3">
<li><strong>Sitemap and RSS links:</strong></li>
</ol>
<p>This repo generates a sitemap via <code>app/sitemap.ts</code>. All URLs must use the full absolute base. Note the <code>export const dynamic = &#39;force-static&#39;</code> — it is required for <code>output: &#39;export&#39;</code>, otherwise the build fails:</p>
<pre><code class="language-typescript">// app/sitemap.ts
import type { MetadataRoute } from &#39;next&#39;;
import { blogPosts } from &#39;@/data/posts&#39;;

export const dynamic = &#39;force-static&#39;;

const BASE_URL = &#39;https://ma-x.im&#39;;

export default function sitemap(): MetadataRoute.Sitemap {
  const posts = blogPosts.map((post) =&gt; ({
    url: \`\${BASE_URL}/blog/\${post.id}\`,
    lastModified: new Date(post.date),
    changeFrequency: &#39;monthly&#39; as const,
    priority: 0.7,
  }));

  return [
    { url: BASE_URL, lastModified: new Date(), changeFrequency: &#39;monthly&#39;, priority: 1 },
    { url: \`\${BASE_URL}/blog\`, lastModified: new Date(), changeFrequency: &#39;weekly&#39;, priority: 0.8 },
    { url: \`\${BASE_URL}/contact\`, lastModified: new Date(), changeFrequency: &#39;yearly&#39;, priority: 0.5 },
    ...posts,
  ];
}
</code></pre>
<p>New blog posts are picked up automatically — the sitemap is built from the same <code>blogPosts</code> array used by the blog pages.</p>
<p>Pair the sitemap with <code>app/robots.ts</code> so search engines can discover it. Same rule applies — <code>export const dynamic = &#39;force-static&#39;</code> is required:</p>
<pre><code class="language-typescript">// app/robots.ts
import type { MetadataRoute } from &#39;next&#39;;

export const dynamic = &#39;force-static&#39;;

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: &#39;*&#39;,
      allow: &#39;/&#39;,
    },
    sitemap: &#39;https://ma-x.im/sitemap.xml&#39;,
  };
}
</code></pre>
<h3>3) Blog pipeline: Markdown + react-markdown (repo-specific)</h3>
<p>This repo stores blog posts as TypeScript files with embedded markdown content, not as <code>.md</code> files. This approach has trade-offs:</p>
<p><strong>Advantages:</strong></p>
<ul>
<li>Type safety on post metadata</li>
<li>No build-time file system reading</li>
<li>Posts are bundled with the application</li>
</ul>
<p><strong>Disadvantages:</strong></p>
<ul>
<li>Content editing requires code changes</li>
<li>Larger JavaScript bundles if content grows</li>
<li>No separation between content and code</li>
</ul>
<p><strong>Post structure</strong> in <code>src/data/posts/types.ts</code>:</p>
<pre><code class="language-typescript">export interface BlogPost {
  id: string;
  title: string;
  excerpt: string;
  tags: string[];
  date: string;
  readTime: string;
  content: string; // markdown content
}
</code></pre>
<p><strong>Static route generation</strong> in <code>src/app/blog/[slug]/page.tsx</code>:</p>
<pre><code class="language-tsx">import { blogPosts } from &#39;@/data/posts/index&#39;;

// Generate static pages for all posts at build time
export async function generateStaticParams() {
  return blogPosts.map((post) =&gt; ({
    slug: post.id,
  }));
}

// Return 404 for slugs not in the list (required for output: &#39;export&#39;)
export const dynamicParams = false;
</code></pre>
<p>The <code>dynamicParams = false</code> is critical. Without it, Next.js assumes dynamic routes might be handled at runtime — incompatible with static export.</p>
<p><strong>Rendering markdown</strong> via <code>src/components/MarkdownContent.tsx</code>:</p>
<pre><code class="language-tsx">&#39;use client&#39;;

import ReactMarkdown from &#39;react-markdown&#39;;
import remarkGfm from &#39;remark-gfm&#39;;
import { Prism as SyntaxHighlighter } from &#39;react-syntax-highlighter&#39;;
import { oneDark } from &#39;react-syntax-highlighter/dist/esm/styles/prism&#39;;

export function MarkdownContent({ content }: { content: string }) {
  return (
    &lt;ReactMarkdown
      remarkPlugins={[remarkGfm]}
      components={{
        code: ({ className, children, ...props }: any) =&gt; {
          const match = /language-(\w+)/.exec(className || &#39;&#39;);
          const codeString = String(children).replace(/\n$/, &#39;&#39;);

          if (match) {
            return (
              &lt;SyntaxHighlighter style={oneDark} language={match[1]} PreTag=&quot;div&quot;&gt;
                {codeString}
              &lt;/SyntaxHighlighter&gt;
            );
          }

          return &lt;code className=&quot;px-2 py-0.5 bg-gray-100 rounded text-sm&quot; {...props}&gt;{children}&lt;/code&gt;;
        },
      }}
    &gt;
      {content}
    &lt;/ReactMarkdown&gt;
  );
}
</code></pre>
<p><strong>Common pitfalls:</strong></p>
<ol>
<li><p><strong>Missing <code>generateStaticParams</code>:</strong> Dynamic routes without static params fail on export with a build error.</p>
</li>
<li><p><strong>Forgetting <code>dynamicParams = false</code>:</strong> The build succeeds, but routes not in <code>generateStaticParams</code> cause errors.</p>
</li>
<li><p><strong>Runtime file system access:</strong> If you read <code>.md</code> files with <code>fs</code>, ensure it happens only in <code>generateStaticParams</code> or <code>generateMetadata</code>, not in component render functions. In App Router, this works correctly since those functions run at build time.</p>
</li>
<li><p><strong>Syntax highlighter SSR issues:</strong> Libraries like <code>react-syntax-highlighter</code> can have hydration mismatches. Marking the component with <code>&#39;use client&#39;</code> and ensuring consistent rendering helps.</p>
</li>
</ol>
<h3>4) GitHub Actions deployment to GitHub Pages</h3>
<p>The existing workflow at <code>.github/workflows/deploy.yml</code>:</p>
<pre><code class="language-yaml">name: Deploy to GitHub Pages

on:
  push:
    branches: [master, new-version-v2]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: &quot;pages&quot;
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &#39;20&#39;
          cache: &#39;npm&#39;

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npx tsc --noEmit

      - name: Build
        run: npm run build

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./out

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4
</code></pre>
<p><strong>Key elements:</strong></p>
<ol>
<li><strong>Permissions:</strong> <code>pages: write</code> and <code>id-token: write</code> are required for the official deploy action. <code>contents: read</code> allows checkout.</li>
<li><strong>Lint and type check before build:</strong> Catches issues before producing artifacts. Lint validates code style, <code>tsc --noEmit</code> verifies types without emitting files.</li>
<li><strong>Concurrency:</strong> Prevents overlapping deployments with <code>cancel-in-progress: false</code> (lets the current deployment finish rather than canceling mid-deploy).</li>
<li><strong>Artifact upload:</strong> The <code>upload-pages-artifact</code> action packages <code>./out</code> (Next.js static export directory) for deployment.</li>
<li><strong>Two-job structure:</strong> Build and deploy are separate jobs. The deploy job waits for build with <code>needs: build</code>.</li>
<li><strong>Environment:</strong> The <code>github-pages</code> environment enables GitHub&#39;s deployment tracking and status checks.</li>
</ol>
<p><strong>Repo settings checklist:</strong></p>
<ol>
<li>Navigate to repository Settings → Pages</li>
<li>Under &quot;Build and deployment&quot;, set Source to &quot;GitHub Actions&quot;</li>
<li>Do not select a branch — the workflow handles deployment</li>
<li>If using a custom domain, enter it under &quot;Custom domain&quot;</li>
<li>Enforce HTTPS (checkbox)</li>
</ol>
<h3>5) Custom domain on GitHub Pages</h3>
<p>This repo uses the custom domain <code>ma-x.im</code>. Setup involves three parts:</p>
<p><strong>1. CNAME file in the repository:</strong></p>
<p>The file <code>public/CNAME</code> contains:</p>
<pre><code>ma-x.im
</code></pre>
<p>This file must exist in the deployed output. Since it&#39;s in <code>public/</code>, Next.js copies it to <code>out/</code> during build. The GitHub Pages deploy action then includes it in the deployment artifact.</p>
<p><strong>2. Repository settings:</strong></p>
<p>In Settings → Pages → Custom domain, enter <code>ma-x.im</code>. GitHub validates DNS and provisions an SSL certificate.</p>
<p><strong>3. DNS configuration:</strong></p>
<p>For an apex domain like <code>ma-x.im</code>, configure DNS at your registrar:</p>
<p><strong>Option A: A records (IPv4)</strong></p>
<pre><code>A     @     185.199.108.153
A     @     185.199.109.153
A     @     185.199.110.153
A     @     185.199.111.153
</code></pre>
<p><strong>Option B: ALIAS/ANAME record (if supported)</strong></p>
<pre><code>ALIAS    @    &lt;user&gt;.github.io
</code></pre>
<p>For a subdomain like <code>www.ma-x.im</code>:</p>
<pre><code>CNAME    www    &lt;user&gt;.github.io
</code></pre>
<p><strong>HTTPS certificate provisioning:</strong></p>
<p>After DNS propagates, GitHub automatically requests a certificate from Let&#39;s Encrypt. This can take minutes to hours depending on DNS propagation. During this time, the site may show a certificate warning.</p>
<p><strong>Keeping CNAME stable:</strong></p>
<p>The current setup (CNAME in <code>public/</code>) works correctly. An alternative for repos where you can&#39;t commit to <code>public/</code>:</p>
<pre><code class="language-yaml">      - name: Build
        run: npm run build

      - name: Add CNAME
        run: echo &quot;ma-x.im&quot; &gt; ./out/CNAME

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./out
</code></pre>
<h3>6) Local verification and &quot;it works on my machine&quot; traps</h3>
<p>After building, verify the static output locally:</p>
<pre><code class="language-bash"># Build the site
npm run build

# Serve the output directory
npx serve out
</code></pre>
<p>This serves at <code>http://localhost:3000</code> by default. All routes should work, including direct navigation to <code>/blog/building-design-systems-that-scale</code>.</p>
<p><strong>Testing basePath locally:</strong></p>
<p>If deploying as a Project Page with <code>basePath: &#39;/my-repo&#39;</code>, the local test needs to simulate the path prefix:</p>
<pre><code class="language-bash">npx serve out -l 3000
# Then access at http://localhost:3000/my-repo/
</code></pre>
<p><strong>&quot;Works locally, broken in production&quot; scenarios:</strong></p>
<ol>
<li><strong>Missing CNAME:</strong> Site deploys but custom domain doesn&#39;t work.</li>
<li><strong>Absolute paths in code:</strong> Work at root locally, break with basePath or different domain.</li>
<li><strong>Environment variables:</strong> <code>NEXT_PUBLIC_*</code> variables are baked in at build time. If you test with <code>.env.local</code> but CI doesn&#39;t have the same values, behavior differs.</li>
<li><strong>Browser caching:</strong> Hard refresh or incognito mode when debugging deployment issues.</li>
</ol>
<h2>Where It Breaks in Enterprise</h2>
<p>Static export works for content sites but hits walls in enterprise contexts:</p>
<p><strong>No server features.</strong> Auth, personalization, A/B testing, and anything requiring runtime logic must move to external services or client-side JavaScript.</p>
<p><strong>Preview and drafts.</strong> Editorial workflows typically need preview deployments of unpublished content. Static export requires rebuilding for every preview — doable but slow.</p>
<p><strong>Search.</strong> Client-side search works for small sites (load a JSON index, search in JavaScript). For larger content, you need an external search service.</p>
<p><strong>Comments.</strong> Disqus, Giscus, or similar third-party embeds. No server means no self-hosted comment storage.</p>
<p><strong>Build time scaling.</strong> As content grows, build times grow. A 1000-post blog might take 10+ minutes to build. This is manageable but affects development velocity.</p>
<p><strong>Image optimization.</strong> Without the Next.js optimization API, you handle image resizing externally or serve unoptimized assets. On fast connections, this is fine. On mobile, it hurts.</p>
<p><strong>Multi-locale with basePath.</strong> Supporting multiple languages with static export works but gets complex with custom domains per locale or locale prefixes combined with basePath.</p>
<p><strong>Analytics and privacy.</strong> Client-side analytics (Google Analytics, Plausible) work. Server-side analytics (for privacy) require a proxy or separate service.</p>
<h2>Common Mistakes</h2>
<p><strong>Forgetting basePath for Project Pages.</strong> Routes and assets return 404. The site looks broken on GitHub but works locally.</p>
<p><strong>Using next/image without <code>unoptimized: true</code>.</strong> Build fails with an error about Image Optimization requiring a server.</p>
<p><strong>Expecting redirects/rewrites to work.</strong> They&#39;re silently ignored on static export. Use client-side navigation or restructure URLs.</p>
<p><strong>Missing <code>generateStaticParams</code> for dynamic routes.</strong> Build error: &quot;Page with <code>output: export</code> has dynamic route but no <code>generateStaticParams</code>.&quot;</p>
<p><strong>Using <code>dynamicParams = true</code> with static export.</strong> Build might succeed but unknown slugs cause issues. Always set <code>dynamicParams = false</code>.</p>
<p><strong>Runtime environment variables.</strong> <code>process.env.MY_VAR</code> in client components doesn&#39;t work — it&#39;s replaced at build time. Use <code>NEXT_PUBLIC_</code> prefix for client-visible values.</p>
<p><strong>Deploying the wrong folder.</strong> Next.js outputs to <code>out/</code> by default, but if you forget to check the workflow, you might deploy <code>.next/</code> or the root folder.</p>
<p><strong>Broken canonical/OG URLs.</strong> Hardcoded URLs that don&#39;t account for basePath or custom domain result in wrong social previews and SEO issues.</p>
<h2>When NOT To Use This</h2>
<p>Static export to GitHub Pages is wrong when:</p>
<ul>
<li>You need authentication or user sessions</li>
<li>Content is personalized per user</li>
<li>You need middleware (geo-redirects, A/B tests at the edge)</li>
<li>You need API routes with runtime logic</li>
<li>Build times exceed acceptable CI limits (hundreds of pages or more)</li>
<li>You need ISR for frequently changing content</li>
</ul>
<p>For these cases, consider Vercel, Netlify, or Cloudflare Pages — platforms with server/edge support that handle these features natively with Next.js.</p>
<h2>How I Apply This in Real Projects</h2>
<p>For personal sites and blogs, I keep the stack minimal: static export, GitHub Pages, and a custom domain. No external services beyond what&#39;s required.</p>
<p>I automate the deployment pipeline once and don&#39;t touch it. The workflow in this repo hasn&#39;t changed significantly since initial setup.</p>
<p>I intentionally don&#39;t build: comments, search (for small sites), analytics (unless required), or complex CMS integrations. Each adds maintenance burden disproportionate to value for personal projects.</p>
<p>When a project genuinely needs server features, I move to a platform that supports them rather than working around static export limitations.</p>
<h2>Practical Recommendations</h2>
<p><strong>Config checklist:</strong></p>
<ul>
<li><code>output: &#39;export&#39;</code> in <code>next.config.ts</code></li>
<li><code>basePath</code> set if deploying to <code>/&lt;repo&gt;/</code> (omit for custom domain)</li>
<li><code>images: { unoptimized: true }</code> if using <code>next/image</code></li>
<li><code>trailingSlash</code> set based on URL preference</li>
<li><code>dynamicParams = false</code> on all dynamic routes</li>
</ul>
<p><strong>Content pipeline:</strong></p>
<ul>
<li><code>generateStaticParams</code> returns all valid slugs</li>
<li>Metadata URLs use full absolute URLs for social sharing</li>
<li>CNAME file in <code>public/</code> for custom domains</li>
</ul>
<p><strong>CI/CD checks:</strong></p>
<ul>
<li>Lint runs before build (<code>npm run lint</code>)</li>
<li>Type check runs before build (<code>npx tsc --noEmit</code>)</li>
<li>Build artifact is correct directory (<code>out/</code>)</li>
<li>Workflow triggers on correct branches</li>
</ul>
<p><strong>Regression tests:</strong></p>
<ul>
<li>Manually verify a few routes after deployment</li>
<li>Check 404 page appears for invalid routes</li>
<li>Verify assets load (images, CSS, JS)</li>
<li>Test OG images in social share debuggers</li>
</ul>
<h2>Summary</h2>
<p>Next.js static export to GitHub Pages is a production-ready approach for content sites. It&#39;s what powers this site, and after running it for a while I can say the setup is genuinely low-maintenance.</p>
<p>The key decisions — <code>output: &#39;export&#39;</code>, <code>images: { unoptimized: true }</code>, <code>generateStaticParams</code> with <code>dynamicParams = false</code> — address the common failure modes. Understanding the constraints (no server runtime, basePath for Project Pages, explicit static generation for dynamic routes) saves you from most surprises.</p>
<p>The approach scales well for small to medium sites. For larger content needs or server-dependent features, migration to a full hosting platform is the right move rather than fighting static export limitations.</p>
<p>If you have feedback, alternative approaches, or just want to discuss this topic — feel free to reach out.
  </p>
]]></content:encoded>
      <pubDate>Wed, 25 Feb 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>GitHub Pages</category>
      <category>SSG</category>
      <category>CI/CD</category>
      <category>DevOps</category>
    </item>
    <item>
      <title>Building Design Systems That Scale</title>
      <link>https://ma-x.im/blog/building-design-systems-that-scale</link>
      <guid isPermaLink="true">https://ma-x.im/blog/building-design-systems-that-scale</guid>
      <description>How to build component libraries that teams actually want to use.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/building-design-systems-that-scale/cover.png" alt="Building Design Systems That Scale" /></p>
<p>At Motorola Solutions I walked into a codebase with hundreds of internal projects. Every team followed the brand book — in theory. In practice, it was chaos. Buttons looked different everywhere. Navigation behaved differently in every app. Everyone thought they were on-brand. Nobody actually was.</p>
<p>They asked me to help build a unified design system. Nine months of work, tight collaboration between designers and developers. The result: a component library that snapped together like Lego. You didn&#39;t think about padding, font sizes, spacing — everything just worked out of the box. We shipped variants for React and Angular simultaneously. The designers had specced the components so precisely that they composed into each other without friction.</p>
<p>Then came adoption. Migration was painful. Versioning became its own nightmare — but I&#39;ll get to that.</p>
<p>Building a design system is the easy part. Getting teams to actually use it is where most fail.</p>
<h2>The Problem</h2>
<p>I&#39;ve watched multiple design system efforts die. Not because the components were poorly designed, but because they didn&#39;t solve the right problems at the right time. Teams would build beautiful libraries, document everything meticulously, and then watch as nobody used them.</p>
<h2>Start with Real Problems</h2>
<p>The best design systems I&#39;ve built started with actual pain points:</p>
<ul>
<li><strong>Inconsistent UI patterns</strong> across features</li>
<li><strong>Slow feature development</strong> due to rebuilding common components</li>
<li><strong>Accessibility gaps</strong> that were hard to track</li>
<li><strong>Design-dev handoff friction</strong> that slowed everything down</li>
</ul>
<h2>Key Principles</h2>
<h3>1. Component API Design Matters</h3>
<p>Your components should be <strong>easy to use correctly</strong> and <strong>hard to use incorrectly</strong>.</p>
<pre><code class="language-tsx">// Bad: Too many props, unclear usage
&lt;Button 
  variant=&quot;primary&quot; 
  size=&quot;md&quot; 
  loading={false} 
  disabled={false}
  onClick={handleClick}
/&gt;

// Good: Clear defaults, composable
&lt;Button onClick={handleClick}&gt;
  Click me
&lt;/Button&gt;

&lt;Button variant=&quot;secondary&quot; loading&gt;
  Loading...
&lt;/Button&gt;
</code></pre>
<h3>2. Documentation is Your Product</h3>
<p>Nobody reads long documentation. Make it:</p>
<ul>
<li><strong>Visual</strong> - Show examples first</li>
<li><strong>Interactive</strong> - Let people play with props</li>
<li><strong>Searchable</strong> - Find what you need fast</li>
<li><strong>Practical</strong> - Real use cases, not toy examples</li>
</ul>
<p>I use Storybook for this. Every component gets:</p>
<ul>
<li>Multiple usage examples</li>
<li>Props documentation</li>
<li>Accessibility notes</li>
<li>Do&#39;s and don&#39;ts</li>
</ul>
<h3>3. Build for Adoption</h3>
<p>The best design system is the one people use. Focus on:</p>
<p><strong>Developer Experience:</strong></p>
<ul>
<li>TypeScript support out of the box</li>
<li>Clear error messages</li>
<li>Intuitive prop names</li>
<li>Good defaults</li>
</ul>
<p><strong>Performance:</strong></p>
<ul>
<li>Tree-shakeable exports</li>
<li>Lazy loading for heavy components</li>
<li>Optimized bundle size</li>
</ul>
<p><strong>Flexibility:</strong></p>
<ul>
<li>Composable components</li>
<li>Escape hatches when needed</li>
<li>Theme customization</li>
</ul>
<h2>The Tech Stack</h2>
<p>Here&#39;s what I typically use:</p>
<ul>
<li><strong>React + TypeScript</strong> - Type safety is non-negotiable</li>
<li><strong>Tailwind CSS</strong> - Utility-first scaling, easy theming</li>
<li><strong>Radix UI</strong> - Accessible primitives</li>
<li><strong>Storybook</strong> - Component documentation</li>
<li><strong>Turborepo</strong> - Monorepo management</li>
<li><strong>Changesets</strong> - Version management</li>
</ul>
<h2>Common Pitfalls</h2>
<h3>Over-engineering Early</h3>
<p>Don&#39;t build 50 components on day one. Start with:</p>
<ol>
<li>Buttons</li>
<li>Inputs</li>
<li>Layout primitives</li>
<li>Typography system</li>
</ol>
<p>Add more as real needs emerge.</p>
<h3>Ignoring Migration Path</h3>
<p>At Motorola Solutions, migration was the hardest part. Hundreds of projects, each with their own component variations. We couldn&#39;t flip a switch. Some teams adopted early, others dragged their feet for months. Eventually every project had the same interface, the same navigation, the same look — and honestly, that felt great. But then versioning caught up with us: some teams locked to an early version and never updated. When they finally did — months later — things broke because the API had evolved. We spent weeks helping teams catch up. If I could redo one thing, it would be investing more in automated migration tooling from day one.</p>
<p>If you&#39;re replacing an old system, make migration gradual:</p>
<ul>
<li>Support both old and new side-by-side</li>
<li>Provide codemods where possible</li>
<li>Document migration guides</li>
<li>Track adoption metrics</li>
</ul>
<h3>Skipping Accessibility</h3>
<p>Build it in from day one. Every component should:</p>
<ul>
<li>Work with keyboard navigation</li>
<li>Have proper ARIA labels</li>
<li>Support screen readers</li>
<li>Handle focus management</li>
</ul>
<h2>Measuring Success</h2>
<p>Track these metrics:</p>
<ol>
<li><strong>Adoption rate</strong> - % of features using the system</li>
<li><strong>Component coverage</strong> - % of UI built with system components</li>
<li><strong>Development velocity</strong> - Time to build new features</li>
<li><strong>Consistency score</strong> - How many UI patterns exist</li>
</ol>
<h2>Conclusion</h2>
<p>After the Motorola Solutions project, I started seeing design systems differently. The system itself isn&#39;t the product — adoption is the product. The best component library in the world is worthless if teams work around it instead of using it. Ours was far from perfect, but it worked because designers and developers built it together, and because the components were genuinely easier to use than rolling your own.</p>
<p>Start small. Iterate based on real feedback from real teams. And always prioritize the developer experience — because if it&#39;s easier to build from scratch than to use your system, that&#39;s exactly what people will do.</p>
<hr>
<p><em>Have questions about building design systems? <a href="https://ma-x.im/contact">Reach out</a> — I&#39;ve been through this and happy to share more.</em>
  </p>
]]></content:encoded>
      <pubDate>Thu, 15 Jan 2026 00:00:00 GMT</pubDate>
      <category>Design Systems</category>
      <category>React</category>
      <category>Architecture</category>
    </item>
    <item>
      <title>Type-Safe React Architecture</title>
      <link>https://ma-x.im/blog/type-safe-react-architecture</link>
      <guid isPermaLink="true">https://ma-x.im/blog/type-safe-react-architecture</guid>
      <description>Leveraging TypeScript for better developer experience and fewer runtime errors.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/type-safe-react-architecture/cover.png" alt="Type-Safe React Architecture" /></p>
<p>I remember the exact moment I realized I couldn&#39;t go back. I had been writing TypeScript for a while, got used to it, and then joined a project that was pure JavaScript. No types. No autocompletion for props. No compile-time errors. Just... hope.</p>
<p>It was unbearable. How do you know what this function returns? You don&#39;t — you read the implementation. What props does this component accept? You check the source. Did someone rename a field in the API response? You&#39;ll find out in production.</p>
<p>The thing about TypeScript is that you don&#39;t see the bugs it prevents. You introduce strict types and your code just... works more reliably. You never get the dramatic &quot;TypeScript saved us from a production outage&quot; moment because the bug never made it past your editor. It stays invisible. But go back to a project without it, and you feel the absence immediately.</p>
<p>TypeScript isn&#39;t just about catching bugs. It&#39;s about building systems that are <strong>impossible to misuse</strong>.</p>
<h2>Why Type Safety Matters</h2>
<p>After 20+ years of engineering, I&#39;ve learned: the best code is code that guides you toward correct usage.</p>
<h3>The Cost of Runtime Errors</h3>
<p>Runtime errors in production are expensive:</p>
<ul>
<li><strong>User frustration</strong> - Broken features hurt trust</li>
<li><strong>Debug time</strong> - Finding issues in production is slow</li>
<li><strong>Lost revenue</strong> - Crashes during checkout cost money</li>
<li><strong>Team velocity</strong> - Fear of breaking things slows development</li>
</ul>
<p>TypeScript moves these errors to <strong>compile time</strong>.</p>
<h2>Architecture Patterns</h2>
<h3>1. Strict Type Configuration</h3>
<p>Start with the strictest possible <code>tsconfig.json</code>:</p>
<pre><code class="language-json">{
  &quot;compilerOptions&quot;: {
    &quot;strict&quot;: true,
    &quot;noUncheckedIndexedAccess&quot;: true,
    &quot;noImplicitReturns&quot;: true,
    &quot;noFallthroughCasesInSwitch&quot;: true,
    &quot;exactOptionalPropertyTypes&quot;: true
  }
}
</code></pre>
<p>Yes, it&#39;s painful at first. But it catches <strong>so many bugs</strong>.</p>
<h3>2. Component Props as Contracts</h3>
<p>Your component props are API contracts:</p>
<pre><code class="language-tsx">// Weak typing
interface ButtonProps {
  variant?: string;
  size?: string;
  onClick?: Function;
}

// Strong typing
type ButtonVariant = &quot;primary&quot; | &quot;secondary&quot; | &quot;ghost&quot;;
type ButtonSize = &quot;sm&quot; | &quot;md&quot; | &quot;lg&quot;;

interface ButtonProps {
  variant?: ButtonVariant;
  size?: ButtonSize;
  onClick?: (event: React.MouseEvent&lt;HTMLButtonElement&gt;) =&gt; void;
  children: React.ReactNode;
}
</code></pre>
<p>Now your IDE will autocomplete variants and catch typos.</p>
<h3>3. Discriminated Unions for State</h3>
<p>Handle complex state with discriminated unions:</p>
<pre><code class="language-tsx">type FetchState&lt;T&gt; =
  | { status: &quot;idle&quot; }
  | { status: &quot;loading&quot; }
  | { status: &quot;success&quot;; data: T }
  | { status: &quot;error&quot;; error: Error };

function UserProfile() {
  const [state, setState] = useState&lt;FetchState&lt;User&gt;&gt;({
    status: &quot;idle&quot;,
  });

  // TypeScript knows what properties exist in each branch
  if (state.status === &quot;success&quot;) {
    return &lt;div&gt;{state.data.name}&lt;/div&gt;; // ✅ data exists
  }

  if (state.status === &quot;error&quot;) {
    return &lt;div&gt;{state.error.message}&lt;/div&gt;; // ✅ error exists
  }

  return &lt;Loading /&gt;;
}
</code></pre>
<p>This pattern <strong>eliminates entire classes of bugs</strong>.</p>
<h3>4. Generic Components Done Right</h3>
<p>Make your components reusable without losing type safety:</p>
<pre><code class="language-tsx">interface SelectOption&lt;T&gt; {
  value: T;
  label: string;
}

interface SelectProps&lt;T&gt; {
  options: SelectOption&lt;T&gt;[];
  value: T;
  onChange: (value: T) =&gt; void;
}

function Select&lt;T&gt;({ options, value, onChange }: SelectProps&lt;T&gt;) {
  return (
    &lt;select
      value={String(value)}
      onChange={(e) =&gt; {
        const option = options.find(
          (opt) =&gt; String(opt.value) === e.target.value
        );
        if (option) onChange(option.value);
      }}
    &gt;
      {options.map((opt) =&gt; (
        &lt;option key={String(opt.value)} value={String(opt.value)}&gt;
          {opt.label}
        &lt;/option&gt;
      ))}
    &lt;/select&gt;
  );
}

// Usage - fully type-safe!
&lt;Select&lt;UserRole&gt;
  options={[
    { value: &quot;admin&quot;, label: &quot;Admin&quot; },
    { value: &quot;user&quot;, label: &quot;User&quot; },
  ]}
  value={role}
  onChange={setRole} // TypeScript knows this is UserRole
/&gt;
</code></pre>
<h3>5. Form Validation with Zod</h3>
<p>Combine React Hook Form with Zod for bulletproof forms:</p>
<pre><code class="language-tsx">import { z } from &quot;zod&quot;;
import { useForm } from &quot;react-hook-form&quot;;
import { zodResolver } from &quot;@hookform/resolvers/zod&quot;;

const userSchema = z.object({
  email: z.string().email(&quot;Invalid email&quot;),
  age: z.number().min(18, &quot;Must be 18+&quot;),
  role: z.enum([&quot;admin&quot;, &quot;user&quot;]),
});

type UserFormData = z.infer&lt;typeof userSchema&gt;;

function UserForm() {
  const { register, handleSubmit, formState: { errors } } = 
    useForm&lt;UserFormData&gt;({
      resolver: zodResolver(userSchema),
    });

  const onSubmit = (data: UserFormData) =&gt; {
    // data is fully typed and validated!
    console.log(data);
  };

  return (
    &lt;form onSubmit={handleSubmit(onSubmit)}&gt;
      &lt;input {...register(&quot;email&quot;)} /&gt;
      {errors.email &amp;&amp; &lt;span&gt;{errors.email.message}&lt;/span&gt;}
    &lt;/form&gt;
  );
}
</code></pre>
<p><strong>One schema</strong>, both runtime validation and TypeScript types.</p>
<h2>API Integration</h2>
<p>Type your API responses:</p>
<pre><code class="language-tsx">// Define your API types
interface User {
  id: string;
  name: string;
  email: string;
}

interface ApiResponse&lt;T&gt; {
  data: T;
  error?: string;
}

// Type-safe API client
async function fetchUser(id: string): Promise&lt;User&gt; {
  const response = await fetch(`/api/users/${id}`);
  const json: ApiResponse&lt;User&gt; = await response.json();
  
  if (json.error) {
    throw new Error(json.error);
  }
  
  return json.data;
}

// Use with TanStack Query
const { data: user } = useQuery({
  queryKey: [&quot;user&quot;, id],
  queryFn: () =&gt; fetchUser(id),
});

// user is typed as User | undefined
</code></pre>
<h2>Testing Benefits</h2>
<p>TypeScript makes testing easier:</p>
<pre><code class="language-tsx">import { render, screen } from &quot;@testing-library/react&quot;;

// TypeScript catches missing props
render(&lt;Button&gt;Click me&lt;/Button&gt;); // ✅

// @ts-expect-error - variant must be valid
render(&lt;Button variant=&quot;invalid&quot;&gt;Click me&lt;/Button&gt;); // ❌
</code></pre>
<h2>Developer Experience Wins</h2>
<p>With proper TypeScript setup:</p>
<ol>
<li><strong>Refactoring is safe</strong> - Rename a prop, find all usages instantly</li>
<li><strong>Autocomplete everywhere</strong> - Your IDE knows what&#39;s valid</li>
<li><strong>Self-documenting code</strong> - Types explain usage</li>
<li><strong>Faster onboarding</strong> - New devs understand APIs from types</li>
<li><strong>Fewer PR comments</strong> - Type checker catches issues</li>
</ol>
<h2>Common Mistakes</h2>
<h3>Using <code>any</code></h3>
<p>Never use <code>any</code>. Use <code>unknown</code> if you must:</p>
<pre><code class="language-tsx">// Bad
function processData(data: any) {
  return data.value; // No safety
}

// Good
function processData(data: unknown) {
  if (typeof data === &quot;object&quot; &amp;&amp; data !== null &amp;&amp; &quot;value&quot; in data) {
    return (data as { value: string }).value;
  }
  throw new Error(&quot;Invalid data&quot;);
}
</code></pre>
<h3>Ignoring Errors</h3>
<p>Don&#39;t <code>@ts-ignore</code> your way out of problems. Fix the types.</p>
<h3>Over-complicating Types</h3>
<p>Keep it simple. Complex types are hard to maintain.</p>
<h2>Conclusion</h2>
<p>Every time I join a project that doesn&#39;t have TypeScript, my first instinct is to add it. Not because I enjoy configuring <code>tsconfig.json</code> — but because I&#39;ve experienced the difference too many times. The bugs you don&#39;t see. The refactors that just work. The onboarding where new developers understand APIs from reading types instead of hoping.</p>
<p>Type safety isn&#39;t optional for serious React applications. It&#39;s the foundation of reliable software, great DX, and confident refactoring. Invest in your type system. Your future self will thank you.</p>
<hr>
<p><em>Want to discuss TypeScript architecture? <a href="https://ma-x.im/contact">Let&#39;s connect</a>.</em>
  </p>
]]></content:encoded>
      <pubDate>Sun, 28 Dec 2025 00:00:00 GMT</pubDate>
      <category>TypeScript</category>
      <category>React</category>
      <category>DX</category>
    </item>
    <item>
      <title>Complex Forms Done Right</title>
      <link>https://ma-x.im/blog/complex-forms-done-right</link>
      <guid isPermaLink="true">https://ma-x.im/blog/complex-forms-done-right</guid>
      <description>Patterns for managing complex form state, validation, and user experience.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/complex-forms-done-right/cover.png" alt="Complex Forms Done Right" /></p>
<p>At Motorola Solutions I built a multi-step contract wizard where users selected products, services, additional options, and pricing tiers — dozens of fields that depended on each other. It wasn&#39;t just a form. It was a live calculator that recalculated totals, toggled conditional sections, and validated business rules at every step.</p>
<p>Later, at an insurance company, I worked on a policy builder that asked everything from property type and vehicle model to how many pets you own and how long you&#39;ve had them. Every answer changed what came next. Every combination produced a different quote.</p>
<p>Forms like these are where most implementations fall apart. A simple login form? Easy. A multi-step wizard with conditional fields, async validation, dynamic pricing, and file uploads? That&#39;s a different engineering problem entirely.</p>
<h2>The Challenge</h2>
<p>Complex forms have multiple concerns:</p>
<ul>
<li><strong>State management</strong> - Field values, touched, dirty states</li>
<li><strong>Validation</strong> - Sync and async, field-level and form-level</li>
<li><strong>UX</strong> - When to show errors, loading states, success feedback</li>
<li><strong>Performance</strong> - Re-renders, large forms, dynamic fields</li>
<li><strong>Accessibility</strong> - Screen readers, keyboard navigation, error announcements</li>
</ul>
<h2>The Right Tools</h2>
<p>After building dozens of complex forms, here&#39;s my stack:</p>
<ul>
<li><strong>React Hook Form</strong> - Uncontrolled forms, great performance</li>
<li><strong>Zod</strong> - Schema validation, type inference</li>
<li><strong>TanStack Query</strong> - Async validation, mutations</li>
<li><strong>Radix UI</strong> - Accessible form primitives</li>
</ul>
<h2>Architecture Patterns</h2>
<h3>1. Schema-First Design</h3>
<p>Define your form schema first:</p>
<pre><code class="language-tsx">import { z } from &quot;zod&quot;;

const addressSchema = z.object({
  street: z.string().min(1, &quot;Street is required&quot;),
  city: z.string().min(1, &quot;City is required&quot;),
  country: z.string().min(1, &quot;Country is required&quot;),
  postalCode: z.string().regex(/^d{5}$/, &quot;Invalid postal code&quot;),
});

const userSchema = z.object({
  email: z.string().email(&quot;Invalid email&quot;),
  password: z
    .string()
    .min(8, &quot;At least 8 characters&quot;)
    .regex(/[A-Z]/, &quot;Need uppercase letter&quot;)
    .regex(/[0-9]/, &quot;Need a number&quot;),
  confirmPassword: z.string(),
  address: addressSchema,
  terms: z.literal(true, {
    errorMap: () =&gt; ({ message: &quot;You must accept terms&quot; }),
  }),
}).refine((data) =&gt; data.password === data.confirmPassword, {
  message: &quot;Passwords don&#39;t match&quot;,
  path: [&quot;confirmPassword&quot;],
});

type UserFormData = z.infer&lt;typeof userSchema&gt;;
</code></pre>
<p><strong>One schema</strong> defines:</p>
<ul>
<li>Field types</li>
<li>Validation rules</li>
<li>Error messages</li>
<li>TypeScript types</li>
</ul>
<h3>2. Async Validation</h3>
<p>Check email availability without blocking the UI:</p>
<pre><code class="language-tsx">const emailSchema = z.string().email().refine(
  async (email) =&gt; {
    const response = await fetch(`/api/check-email?email=${email}`);
    const { available } = await response.json();
    return available;
  },
  { message: &quot;Email already taken&quot; }
);
</code></pre>
<p>With React Hook Form:</p>
<pre><code class="language-tsx">&lt;input
  {...register(&quot;email&quot;, {
    validate: async (value) =&gt; {
      const result = await emailSchema.safeParseAsync(value);
      return result.success || result.error.errors[0].message;
    },
  })}
/&gt;
</code></pre>
<h3>3. Multi-Step Forms</h3>
<p>Break complex forms into steps:</p>
<pre><code class="language-tsx">function MultiStepForm() {
  const [step, setStep] = useState(1);
  const methods = useForm&lt;UserFormData&gt;();

  const onSubmit = async (data: UserFormData) =&gt; {
    if (step &lt; 3) {
      setStep(step + 1);
      return;
    }

    // Final submission
    await createUser(data);
  };

  return (
    &lt;FormProvider {...methods}&gt;
      &lt;form onSubmit={methods.handleSubmit(onSubmit)}&gt;
        {step === 1 &amp;&amp; &lt;PersonalInfoStep /&gt;}
        {step === 2 &amp;&amp; &lt;AddressStep /&gt;}
        {step === 3 &amp;&amp; &lt;ReviewStep /&gt;}

        &lt;div&gt;
          {step &gt; 1 &amp;&amp; (
            &lt;button type=&quot;button&quot; onClick={() =&gt; setStep(step - 1)}&gt;
              Back
            &lt;/button&gt;
          )}
          &lt;button type=&quot;submit&quot;&gt;
            {step &lt; 3 ? &quot;Next&quot; : &quot;Submit&quot;}
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/FormProvider&gt;
  );
}
</code></pre>
<h3>4. Dynamic Field Arrays</h3>
<p>Add/remove fields dynamically:</p>
<pre><code class="language-tsx">import { useFieldArray } from &quot;react-hook-form&quot;;

function SkillsForm() {
  const { control, register } = useForm();
  const { fields, append, remove } = useFieldArray({
    control,
    name: &quot;skills&quot;,
  });

  return (
    &lt;div&gt;
      {fields.map((field, index) =&gt; (
        &lt;div key={field.id}&gt;
          &lt;input
            {...register(`skills.${index}.name`)}
            placeholder=&quot;Skill name&quot;
          /&gt;
          &lt;input
            type=&quot;number&quot;
            {...register(`skills.${index}.years`)}
            placeholder=&quot;Years&quot;
          /&gt;
          &lt;button type=&quot;button&quot; onClick={() =&gt; remove(index)}&gt;
            Remove
          &lt;/button&gt;
        &lt;/div&gt;
      ))}
      
      &lt;button
        type=&quot;button&quot;
        onClick={() =&gt; append({ name: &quot;&quot;, years: 0 })}
      &gt;
        Add Skill
      &lt;/button&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3>5. Conditional Fields</h3>
<p>Show/hide fields based on other values:</p>
<pre><code class="language-tsx">function PaymentForm() {
  const { register, watch } = useForm();
  const paymentMethod = watch(&quot;paymentMethod&quot;);

  return (
    &lt;div&gt;
      &lt;select {...register(&quot;paymentMethod&quot;)}&gt;
        &lt;option value=&quot;card&quot;&gt;Credit Card&lt;/option&gt;
        &lt;option value=&quot;bank&quot;&gt;Bank Transfer&lt;/option&gt;
      &lt;/select&gt;

      {paymentMethod === &quot;card&quot; &amp;&amp; (
        &lt;&gt;
          &lt;input {...register(&quot;cardNumber&quot;)} placeholder=&quot;Card Number&quot; /&gt;
          &lt;input {...register(&quot;cvv&quot;)} placeholder=&quot;CVV&quot; /&gt;
        &lt;/&gt;
      )}

      {paymentMethod === &quot;bank&quot; &amp;&amp; (
        &lt;&gt;
          &lt;input {...register(&quot;accountNumber&quot;)} placeholder=&quot;Account&quot; /&gt;
          &lt;input {...register(&quot;routingNumber&quot;)} placeholder=&quot;Routing&quot; /&gt;
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<h2>Error Handling UX</h2>
<p>Show errors at the right time:</p>
<pre><code class="language-tsx">function SmartInput({ name, label, ...props }) {
  const {
    register,
    formState: { errors, touchedFields, isSubmitted },
  } = useFormContext();

  const error = errors[name];
  const showError = error &amp;&amp; (touchedFields[name] || isSubmitted);

  return (
    &lt;div&gt;
      &lt;label htmlFor={name}&gt;{label}&lt;/label&gt;
      &lt;input
        id={name}
        aria-invalid={showError ? &quot;true&quot; : &quot;false&quot;}
        aria-describedby={showError ? `${name}-error` : undefined}
        {...register(name)}
        {...props}
      /&gt;
      {showError &amp;&amp; (
        &lt;span id={`${name}-error`} role=&quot;alert&quot;&gt;
          {error.message}
        &lt;/span&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>Only show errors after:</p>
<ol>
<li>User has touched the field, OR</li>
<li>Form has been submitted</li>
</ol>
<p>This prevents angry red errors while typing.</p>
<h2>Performance Optimization</h2>
<p>For large forms (50+ fields):</p>
<pre><code class="language-tsx">const { register } = useForm({
  mode: &quot;onBlur&quot;, // Validate on blur, not on change
  shouldUnregister: true, // Unregister unmounted fields
});
</code></pre>
<p>Use <code>Controller</code> only for complex inputs:</p>
<pre><code class="language-tsx">import { Controller } from &quot;react-hook-form&quot;;

&lt;Controller
  name=&quot;birthDate&quot;
  control={control}
  render={({ field }) =&gt; (
    &lt;DatePicker
      value={field.value}
      onChange={field.onChange}
    /&gt;
  )}
/&gt;
</code></pre>
<h2>File Uploads</h2>
<p>Handle files properly:</p>
<pre><code class="language-tsx">function FileUpload() {
  const { register, watch } = useForm();
  const file = watch(&quot;avatar&quot;);

  return (
    &lt;div&gt;
      &lt;input
        type=&quot;file&quot;
        accept=&quot;image/*&quot;
        {...register(&quot;avatar&quot;)}
      /&gt;
      {file?.[0] &amp;&amp; (
        &lt;img
          src={URL.createObjectURL(file[0])}
          alt=&quot;Preview&quot;
        /&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<h2>Submission with TanStack Query</h2>
<p>Clean mutation handling:</p>
<pre><code class="language-tsx">function UserForm() {
  const methods = useForm&lt;UserFormData&gt;();
  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () =&gt; {
      toast.success(&quot;User created!&quot;);
      methods.reset();
    },
    onError: (error) =&gt; {
      toast.error(error.message);
    },
  });

  const onSubmit = (data: UserFormData) =&gt; {
    mutation.mutate(data);
  };

  return (
    &lt;form onSubmit={methods.handleSubmit(onSubmit)}&gt;
      {/* fields */}
      &lt;button type=&quot;submit&quot; disabled={mutation.isPending}&gt;
        {mutation.isPending ? &quot;Saving...&quot; : &quot;Save&quot;}
      &lt;/button&gt;
    &lt;/form&gt;
  );
}
</code></pre>
<h2>Accessibility Checklist</h2>
<p>✅ Labels for every input
✅ Error messages announced to screen readers
✅ Keyboard navigation works
✅ Focus management (auto-focus first error)
✅ Required fields marked
✅ Clear validation feedback</p>
<h2>Testing</h2>
<p>Test forms thoroughly:</p>
<pre><code class="language-tsx">import { render, screen, waitFor } from &quot;@testing-library/react&quot;;
import userEvent from &quot;@testing-library/user-event&quot;;

test(&quot;shows validation errors&quot;, async () =&gt; {
  render(&lt;UserForm /&gt;);
  
  const user = userEvent.setup();
  const submitButton = screen.getByRole(&quot;button&quot;, { name: /submit/i });
  
  await user.click(submitButton);
  
  await waitFor(() =&gt; {
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
  });
});
</code></pre>
<h2>Conclusion</h2>
<p>The Motorola contract wizard and the insurance policy builder taught me the same lesson: complex forms aren&#39;t a UI problem — they&#39;re a data modeling problem. Get the schema right first, handle validation at the schema level, and the rest follows.</p>
<p>Schema-first validation. Smart error timing. Performance-aware rendering. Accessibility from day one. Thorough testing. Get these right, and even the most complex forms become manageable.</p>
<hr>
<p><em>Need help with complex forms? <a href="https://ma-x.im/contact">Get in touch</a>.</em>
  </p>
]]></content:encoded>
      <pubDate>Wed, 10 Dec 2025 00:00:00 GMT</pubDate>
      <category>Forms</category>
      <category>React Hook Form</category>
      <category>Architecture</category>
    </item>
    <item>
      <title>React Performance Optimization</title>
      <link>https://ma-x.im/blog/react-performance-optimization</link>
      <guid isPermaLink="true">https://ma-x.im/blog/react-performance-optimization</guid>
      <description>Practical techniques for building fast React applications at scale.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/react-performance-optimization/cover.png" alt="React Performance Optimization" /></p>
<p>I joined a large Canadian automotive company as one of three senior frontend engineers. The site was public-facing — a product catalog where users browsed vehicle models, configurations, and pricing. Built on Sitecore with React components on top.</p>
<p>The first thing I noticed: the catalog page re-rendered on every interaction. Not just the part you touched — everything. The page pulled data from separate endpoints for vehicle types, models, specifications, and pricing. Every state change triggered a cascade of re-renders across all of them. It was the first time in my career I saw genuine, visible lag on a production public website — not some internal admin panel, but a site real customers used every day.</p>
<p>That became our first priority. We spent weeks profiling, restructuring data flow, and in some cases rewriting components from scratch because the original code was unsalvageable. The performance boost was dramatic and became our biggest early win with the team.</p>
<p>That experience taught me something I keep coming back to: performance isn&#39;t about premature optimization. It&#39;s about building systems that stay fast as they grow.</p>
<h2>When to Optimize</h2>
<p>Don&#39;t optimize everything. Optimize when:</p>
<ul>
<li>Users report slowness</li>
<li>Metrics show issues (Core Web Vitals)</li>
<li>Profiling reveals bottlenecks</li>
<li>You&#39;re building data-heavy features</li>
</ul>
<p><strong>Measure first, optimize second.</strong></p>
<h2>Profiling Tools</h2>
<p>Use the right tools:</p>
<ol>
<li><strong>React DevTools Profiler</strong> - See component render times</li>
<li><strong>Chrome Performance</strong> - Analyze full page performance</li>
<li><strong>Web Vitals</strong> - Track real user metrics</li>
<li><strong>Lighthouse</strong> - Automated audits</li>
</ol>
<h2>Common Performance Issues</h2>
<h3>1. Unnecessary Re-renders</h3>
<p><strong>Problem:</strong> Component re-renders when props haven&#39;t changed.</p>
<pre><code class="language-tsx">// Bad: Creates new object every render
function Parent() {
  return &lt;Child config={{ theme: &quot;dark&quot; }} /&gt;;
}

// Good: Stable reference
const CONFIG = { theme: &quot;dark&quot; };

function Parent() {
  return &lt;Child config={CONFIG} /&gt;;
}

// Or use useMemo for dynamic values
function Parent() {
  const config = useMemo(() =&gt; ({ theme: &quot;dark&quot; }), []);
  return &lt;Child config={config} /&gt;;
}
</code></pre>
<p><strong>Use <code>React.memo</code> wisely:</strong></p>
<pre><code class="language-tsx">const ExpensiveComponent = React.memo(({ data }) =&gt; {
  // Heavy rendering logic
  return &lt;div&gt;{/* ... */}&lt;/div&gt;;
});
</code></pre>
<h3>2. Large Lists Without Virtualization</h3>
<p><strong>Problem:</strong> Rendering 10,000 rows kills performance.</p>
<p><strong>Solution:</strong> Use virtualization.</p>
<pre><code class="language-tsx">import { useVirtualizer } from &quot;@tanstack/react-virtual&quot;;

function VirtualList({ items }) {
  const parentRef = useRef&lt;HTMLDivElement&gt;(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () =&gt; parentRef.current,
    estimateSize: () =&gt; 50,
  });

  return (
    &lt;div ref={parentRef} style={{ height: &quot;500px&quot;, overflow: &quot;auto&quot; }}&gt;
      &lt;div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: &quot;relative&quot;,
        }}
      &gt;
        {virtualizer.getVirtualItems().map((virtualRow) =&gt; (
          &lt;div
            key={virtualRow.index}
            style={{
              position: &quot;absolute&quot;,
              top: 0,
              left: 0,
              width: &quot;100%&quot;,
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          &gt;
            {items[virtualRow.index].name}
          &lt;/div&gt;
        ))}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Only renders visible items. Huge performance win.</p>
<h3>3. Heavy Computations in Render</h3>
<p><strong>Problem:</strong> Expensive calculations on every render.</p>
<pre><code class="language-tsx">// Bad: Recalculates every render
function DataTable({ data }) {
  const sorted = data.sort((a, b) =&gt; a.value - b.value);
  const filtered = sorted.filter(item =&gt; item.active);
  
  return &lt;Table data={filtered} /&gt;;
}

// Good: Memoized
function DataTable({ data }) {
  const processed = useMemo(() =&gt; {
    const sorted = [...data].sort((a, b) =&gt; a.value - b.value);
    return sorted.filter(item =&gt; item.active);
  }, [data]);
  
  return &lt;Table data={processed} /&gt;;
}
</code></pre>
<h3>4. Inline Functions as Props</h3>
<p><strong>Problem:</strong> New function every render = child re-renders.</p>
<pre><code class="language-tsx">// Bad
function Parent() {
  return (
    &lt;Child onClick={() =&gt; console.log(&quot;clicked&quot;)} /&gt;
  );
}

// Good
function Parent() {
  const handleClick = useCallback(() =&gt; {
    console.log(&quot;clicked&quot;);
  }, []);
  
  return &lt;Child onClick={handleClick} /&gt;;
}
</code></pre>
<h3>5. Context Provider Re-renders</h3>
<p><strong>Problem:</strong> Context value changes = all consumers re-render.</p>
<pre><code class="language-tsx">// Bad: New object every render
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(&quot;light&quot;);
  
  return (
    &lt;ThemeContext.Provider value={{ theme, setTheme }}&gt;
      {children}
    &lt;/ThemeContext.Provider&gt;
  );
}

// Good: Memoized value
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(&quot;light&quot;);
  
  const value = useMemo(() =&gt; ({ theme, setTheme }), [theme]);
  
  return (
    &lt;ThemeContext.Provider value={value}&gt;
      {children}
    &lt;/ThemeContext.Provider&gt;
  );
}
</code></pre>
<h2>Code Splitting</h2>
<p>Load code only when needed:</p>
<pre><code class="language-tsx">import { lazy, Suspense } from &quot;react&quot;;

const Dashboard = lazy(() =&gt; import(&quot;./Dashboard&quot;));
const Settings = lazy(() =&gt; import(&quot;./Settings&quot;));

function App() {
  return (
    &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
      &lt;Routes&gt;
        &lt;Route path=&quot;/dashboard&quot; element={&lt;Dashboard /&gt;} /&gt;
        &lt;Route path=&quot;/settings&quot; element={&lt;Settings /&gt;} /&gt;
      &lt;/Routes&gt;
    &lt;/Suspense&gt;
  );
}
</code></pre>
<h2>Image Optimization</h2>
<p>Images are usually the biggest assets:</p>
<pre><code class="language-tsx">// Use modern formats
&lt;img
  src=&quot;hero.webp&quot;
  srcSet=&quot;hero-sm.webp 400w, hero-md.webp 800w, hero-lg.webp 1200w&quot;
  sizes=&quot;(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px&quot;
  alt=&quot;Hero&quot;
  loading=&quot;lazy&quot;
/&gt;
</code></pre>
<p>Use <code>loading=&quot;lazy&quot;</code> for below-the-fold images.</p>
<h2>State Management Performance</h2>
<h3>Use Zustand for Better Performance</h3>
<pre><code class="language-tsx">import { create } from &quot;zustand&quot;;

const useStore = create((set) =&gt; ({
  todos: [],
  addTodo: (todo) =&gt; set((state) =&gt; ({
    todos: [...state.todos, todo]
  })),
}));

// Only re-renders when todos change
function TodoList() {
  const todos = useStore((state) =&gt; state.todos);
  return &lt;div&gt;{/* ... */}&lt;/div&gt;;
}

// Never re-renders
function AddTodo() {
  const addTodo = useStore((state) =&gt; state.addTodo);
  return &lt;button onClick={() =&gt; addTodo({ text: &quot;New&quot; })}&gt;Add&lt;/button&gt;;
}
</code></pre>
<h3>TanStack Query for Server State</h3>
<pre><code class="language-tsx">const { data } = useQuery({
  queryKey: [&quot;users&quot;],
  queryFn: fetchUsers,
  staleTime: 5 * 60 * 1000, // 5 minutes
});
</code></pre>
<p>Automatic caching, deduplication, background refetching.</p>
<h2>Bundle Size Optimization</h2>
<h3>Tree Shaking</h3>
<pre><code class="language-tsx">// Bad: Imports entire library
import _ from &quot;lodash&quot;;

// Good: Import only what you need
import debounce from &quot;lodash/debounce&quot;;
</code></pre>
<h3>Analyze Bundle</h3>
<pre><code class="language-bash">npm install --save-dev vite-plugin-bundle-analyzer
</code></pre>
<p>Find and remove large dependencies.</p>
<h2>React Compiler (Coming Soon)</h2>
<p>React 19+ will have automatic memoization. But until then, manual optimization is needed.</p>
<h2>Performance Checklist</h2>
<p>✅ Profile before optimizing
✅ Use React.memo for expensive components
✅ Virtualize large lists
✅ Code split routes
✅ Lazy load images
✅ Optimize images (WebP, sizing)
✅ Memoize expensive computations
✅ Use proper state management
✅ Analyze bundle size
✅ Monitor Core Web Vitals</p>
<h2>Conclusion</h2>
<p>After the automotive catalog project, I never look at performance the same way. When it&#39;s bad, users feel it immediately — and they leave. When you fix it, the numbers speak for themselves. Every project where I&#39;ve invested in performance early paid it back many times over.</p>
<p>Measure with real tools. Profile before you guess. Fix the actual bottlenecks, not the ones you imagine. And keep monitoring in production — performance degrades silently if nobody&#39;s watching.</p>
<hr>
<p><em>Need help with React performance? <a href="https://ma-x.im/contact">Let&#39;s talk</a>.</em>
  </p>
]]></content:encoded>
      <pubDate>Sat, 22 Nov 2025 00:00:00 GMT</pubDate>
      <category>React</category>
      <category>Performance</category>
      <category>Optimization</category>
    </item>
    <item>
      <title>Monorepo Best Practices</title>
      <link>https://ma-x.im/blog/monorepo-best-practices</link>
      <guid isPermaLink="true">https://ma-x.im/blog/monorepo-best-practices</guid>
      <description>How to structure and manage large-scale monorepo projects effectively.</description>
      <content:encoded><![CDATA[<p><img src="https://ma-x.im/blog/monorepo-best-practices/cover.png" alt="Monorepo Best Practices" /></p>
<p>I learned to love monorepos the hard way — by suffering through the alternative.</p>
<p>At a large Canadian automotive company, we had a repository for basic UI components, a separate repository for complex components that depended on them, and then six separate repositories — one for each product line: cars, motorcycles, engines, marine, power equipment, and more. Plus utility repos for shared configs, linting rules, and deployment scripts.</p>
<p>When you released a patch to the base components, the chain reaction began. Update base components, then update complex components, then update all six product repos. Something always broke somewhere in that chain. A CI job would hang. A version mismatch would surface. A team using an older version would suddenly discover their build was broken. We outsourced CI/CD to external contractors, which added another layer of friction — every issue turned into a cross-team investigation.</p>
<p>We even started preparing a plan to consolidate everything into a single monorepo. I left the company before they finished.</p>
<p>That experience made me a firm believer: if your projects share code, put them together.</p>
<h2>Why Monorepo?</h2>
<p><strong>Benefits:</strong></p>
<ul>
<li><strong>Shared code</strong> — one source of truth for components, utils</li>
<li><strong>Atomic changes</strong> — update library and consumers in one PR</li>
<li><strong>Better refactoring</strong> — see all usages across projects</li>
<li><strong>Unified tooling</strong> — one config for linting, testing, building</li>
<li><strong>Easier onboarding</strong> — everything in one repo</li>
</ul>
<p><strong>Drawbacks:</strong></p>
<ul>
<li><strong>Slower CI</strong> if not optimized</li>
<li><strong>More complex tooling</strong> needed</li>
<li><strong>Harder access control</strong> (everything visible)</li>
</ul>
<p>For most teams, benefits outweigh drawbacks. And for teams that have lived through multi-repo dependency hell — it&#39;s not even a question.</p>
<h2>Tools</h2>
<p>I use <strong>Turborepo</strong>. It&#39;s fast, simple, and works great with npm/pnpm.</p>
<p>Alternatives:</p>
<ul>
<li><strong>Nx</strong> - More features, steeper learning curve</li>
<li><strong>Lerna</strong> - Older, less maintained</li>
<li><strong>Rush</strong> - Microsoft&#39;s tool, great for large teams</li>
</ul>
<h2>Structure</h2>
<p>Clean structure is critical:</p>
<pre><code>my-monorepo/
├── apps/
│   ├── web/              # Main web app
│   ├── admin/            # Admin dashboard
│   └── mobile/           # React Native app
├── packages/
│   ├── ui/               # Shared component library
│   ├── utils/            # Shared utilities
│   ├── config/           # Shared configs (eslint, ts)
│   └── types/            # Shared TypeScript types
├── tooling/
│   ├── eslint-config/
│   └── typescript-config/
├── package.json
├── turbo.json
└── pnpm-workspace.yaml
</code></pre>
<p><strong>Apps</strong> = Deployable applications
<strong>Packages</strong> = Shared libraries
<strong>Tooling</strong> = Dev tools and configs</p>
<h2>Package Setup</h2>
<p>Each package has its own <code>package.json</code>:</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;@repo/ui&quot;,
  &quot;version&quot;: &quot;0.0.0&quot;,
  &quot;private&quot;: true,
  &quot;main&quot;: &quot;./src/index.ts&quot;,
  &quot;types&quot;: &quot;./src/index.ts&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: &quot;./src/index.ts&quot;,
    &quot;./button&quot;: &quot;./src/button.tsx&quot;
  }
}
</code></pre>
<p>Apps import packages:</p>
<pre><code class="language-tsx">import { Button } from &quot;@repo/ui&quot;;
import { formatDate } from &quot;@repo/utils&quot;;
</code></pre>
<p>TypeScript resolves these via workspace protocol.</p>
<h2>Turborepo Configuration</h2>
<p><code>turbo.json</code>:</p>
<pre><code class="language-json">{
  &quot;tasks&quot;: {
    &quot;build&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;],
      &quot;outputs&quot;: [&quot;dist/**&quot;, &quot;.next/**&quot;]
    },
    &quot;test&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;]
    },
    &quot;lint&quot;: {},
    &quot;dev&quot;: {
      &quot;cache&quot;: false,
      &quot;persistent&quot;: true
    }
  }
}
</code></pre>
<p><strong>Key features:</strong></p>
<ul>
<li><code>dependsOn</code> - Build dependencies first</li>
<li><code>outputs</code> - Cache these directories</li>
<li><code>cache: false</code> - Don&#39;t cache dev server</li>
</ul>
<h2>Workspace Protocol</h2>
<p>Use <code>pnpm</code> for best monorepo support:</p>
<p><code>pnpm-workspace.yaml</code>:</p>
<pre><code class="language-yaml">packages:
  - &quot;apps/*&quot;
  - &quot;packages/*&quot;
  - &quot;tooling/*&quot;
</code></pre>
<p>Install workspace dependencies:</p>
<pre><code class="language-json">{
  &quot;dependencies&quot;: {
    &quot;@repo/ui&quot;: &quot;workspace:*&quot;,
    &quot;@repo/utils&quot;: &quot;workspace:*&quot;
  }
}
</code></pre>
<h2>Shared Configuration</h2>
<p>Share ESLint config:</p>
<pre><code class="language-js">// tooling/eslint-config/index.js
module.exports = {
  extends: [
    &quot;eslint:recommended&quot;,
    &quot;plugin:@typescript-eslint/recommended&quot;,
    &quot;plugin:react/recommended&quot;,
  ],
  // ... rules
};
</code></pre>
<p>Use in packages:</p>
<pre><code class="language-json">{
  &quot;eslintConfig&quot;: {
    &quot;extends&quot;: [&quot;@repo/eslint-config&quot;]
  }
}
</code></pre>
<p>Same pattern for TypeScript, Prettier, Jest configs.</p>
<h2>CI/CD Optimization</h2>
<p><strong>Problem:</strong> CI runs all tests on every commit, even if code didn&#39;t change.</p>
<p><strong>Solution:</strong> Turborepo&#39;s remote caching.</p>
<pre><code class="language-bash"># Only rebuild what changed
turbo run build --filter=[HEAD^1]

# Use remote cache
turbo run build --cache-dir=.turbo/cache
</code></pre>
<p>GitHub Actions example:</p>
<pre><code class="language-yaml">name: CI

on: [pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: &#39;pnpm&#39;
      
      - run: pnpm install
      
      - name: Build
        run: pnpm turbo run build --filter=[HEAD^1]
      
      - name: Test
        run: pnpm turbo run test --filter=[HEAD^1]
</code></pre>
<p><strong>Huge time savings</strong> on large PRs.</p>
<h2>Versioning Strategy</h2>
<p>For internal packages (not published to npm):</p>
<pre><code class="language-json">{
  &quot;version&quot;: &quot;0.0.0&quot;,
  &quot;private&quot;: true
}
</code></pre>
<p>For published packages, use <strong>Changesets</strong>:</p>
<pre><code class="language-bash">pnpm add -DW @changesets/cli
pnpm changeset init
</code></pre>
<p>Create changeset:</p>
<pre><code class="language-bash">pnpm changeset
</code></pre>
<p>It asks:</p>
<ul>
<li>Which packages changed?</li>
<li>Semver bump (major/minor/patch)?</li>
<li>Changelog entry?</li>
</ul>
<p>Then in CI:</p>
<pre><code class="language-bash">pnpm changeset version
pnpm changeset publish
</code></pre>
<p>Automatic versioning and publishing.</p>
<h2>Common Pitfalls</h2>
<h3>1. Not Using Task Dependencies</h3>
<pre><code class="language-json">{
  &quot;tasks&quot;: {
    &quot;build&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;] // Build deps first!
    }
  }
}
</code></pre>
<p>Without this, apps might build before their dependencies.</p>
<h3>2. Circular Dependencies</h3>
<p><strong>Never</strong> create circular dependencies:</p>
<pre><code>❌ @repo/ui -&gt; @repo/utils -&gt; @repo/ui
</code></pre>
<p>Keep dependency graph acyclic.</p>
<h3>3. Too Many Small Packages</h3>
<p>Don&#39;t create a package for every util function. Group related code:</p>
<pre><code>✅ @repo/utils (contains formatDate, parseUrl, etc.)
❌ @repo/format-date, @repo/parse-url, ...
</code></pre>
<h3>4. Ignoring Cache Configuration</h3>
<p>Configure <code>outputs</code> properly:</p>
<pre><code class="language-json">{
  &quot;tasks&quot;: {
    &quot;build&quot;: {
      &quot;outputs&quot;: [&quot;dist/**&quot;, &quot;build/**&quot;, &quot;.next/**&quot;]
    }
  }
}
</code></pre>
<p>Missing this = no caching benefits.</p>
<h2>Testing in Monorepo</h2>
<p>Shared Jest config:</p>
<pre><code class="language-js">// packages/jest-config/jest.config.js
module.exports = {
  preset: &quot;ts-jest&quot;,
  testEnvironment: &quot;jsdom&quot;,
  moduleNameMapper: {
    &quot;^@repo/(.*)$&quot;: &quot;&lt;rootDir&gt;/../$1/src&quot;,
  },
};
</code></pre>
<p>Run all tests:</p>
<pre><code class="language-bash">turbo run test
</code></pre>
<p>Run tests for specific package:</p>
<pre><code class="language-bash">turbo run test --filter=@repo/ui
</code></pre>
<h2>Development Workflow</h2>
<p>Start all apps in dev mode:</p>
<pre><code class="language-bash">turbo run dev --parallel
</code></pre>
<p>Work on specific app:</p>
<pre><code class="language-bash">turbo run dev --filter=web
</code></pre>
<p>Watch mode for package:</p>
<pre><code class="language-bash">turbo run dev --filter=@repo/ui
</code></pre>
<h2>Migration Strategy</h2>
<p>Moving to monorepo? Do it gradually:</p>
<ol>
<li><strong>Setup monorepo structure</strong></li>
<li><strong>Move shared code</strong> to packages</li>
<li><strong>Migrate one app</strong> at a time</li>
<li><strong>Update CI/CD</strong> incrementally</li>
<li><strong>Train team</strong> on new workflows</li>
</ol>
<p>Don&#39;t do big-bang migrations. Too risky.</p>
<h2>Conclusion</h2>
<p>Every time I set up a monorepo from scratch, I think about those six separate repos and the hours we spent chasing version mismatches across them. The initial monorepo setup takes effort — no question. But the alternative is worse: scattered code, broken dependency chains, and a CI pipeline that nobody fully understands.</p>
<p>Get the structure right. Invest in proper tooling. Cache aggressively. And keep your dependency graph clean. Future you will be grateful.</p>
<hr>
<p><em>Questions about monorepos? <a href="https://ma-x.im/contact">Reach out</a>.</em>
  </p>
]]></content:encoded>
      <pubDate>Wed, 05 Nov 2025 00:00:00 GMT</pubDate>
      <category>Monorepo</category>
      <category>Architecture</category>
      <category>DevOps</category>
    </item>
  </channel>
</rss>