<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Alexander Antoniades - TalentLMS Tech Blog</title><link>https://blog.talentlms.io/authors/alexander-antoniades/</link><description>Posts by Alexander Antoniades - Insights and stories from the TalentLMS engineering team. Technical deep-dives, team culture, and lessons learned building learning management software.</description><language>en-US</language><managingEditor>noreply@talentlms.com (TalentLMS Engineering Team)</managingEditor><webMaster>noreply@talentlms.com (TalentLMS Engineering Team)</webMaster><lastBuildDate>Mon, 20 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.talentlms.io/authors/alexander-antoniades/feed.xml" rel="self" type="application/rss+xml"/><image><url>https://blog.talentlms.io/images/logo/logo.svg</url><title>TalentLMS Tech Blog</title><link>https://blog.talentlms.io/</link></image><item><title>Feature-Based Architecture: Why We Organize Code by Feature</title><link>https://blog.talentlms.io/posts/where-does-this-file-go/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0000</pubDate><dc:creator>Alexander Antoniades</dc:creator><guid>https://blog.talentlms.io/posts/where-does-this-file-go/</guid><description>You finish a feature and you're holding a hook, a helper, a type, and a
stylesheet. Where do they go? For a while our answer was "it depends," which
is another way of saying nobody knew.
This is how we settled on one folder per feature, and how we stopped it from
drifting back into a mess.</description><enclosure url="https://blog.talentlms.io/images/posts/where-does-this-file-go.jpg" type="image/png"/><media:content url="https://blog.talentlms.io/images/posts/where-does-this-file-go.jpg" medium="image"><media:title type="plain">A hand pulling a labeled index card from the wooden drawer of an old library card catalog, with rows of cards fanned out behind it.</media:title><media:description type="plain">A hand pulling a labeled index card from the wooden drawer of an old library card catalog, with rows of cards fanned out behind it.</media:description></media:content><content:encoded><![CDATA[<img src="https://blog.talentlms.io/images/posts/where-does-this-file-go.jpg" alt="A hand pulling a labeled index card from the wooden drawer of an old library card catalog, with rows of cards fanned out behind it." style="max-width: 100%; height: auto; margin-bottom: 1.5em;" /><p style="margin-bottom: 1.5em; padding: 1em; background-color: #f5f5f5; border-left: 4px solid #0066cc;"><strong>Alexander Antoniades</strong>, Frontend Chapter Lead<br/>Alexander Antoniades is the Frontend Chapter Lead at TalentLMS. He spends his days obsessing over web performance and front-end architecture — the kind of work where shaving off 50 milliseconds feels …</p><p>You just finished a feature. Nothing dramatic. A small screen, a form, a couple of helpers. Now you&rsquo;re sitting there holding the leftovers. A custom hook. A little utility function. A type or two. A stylesheet. And you have to decide where each one goes.</p>
<p>In the old codebase, that was a real decision. The hook could go in the global <code>hooks/</code> folder. The helper could go in <code>utils/</code>. The type in <code>types/</code>. The style next to something, somewhere. Every file you wrote came with a small fork in the road, and everyone picked a slightly different branch.</p>
<p>So the code for one feature ended up smeared across five different top-level folders. To understand the catalog page you opened <code>components/</code>, then <code>hooks/</code>, then <code>utils/</code>, then <code>types/</code>, and none of those folders was called catalog. The feature existed. It just didn&rsquo;t live anywhere. It was scattered.</p>
<h2 id="organizing-by-type-sounds-tidy">Organizing by type sounds tidy</h2>
<p>The instinct behind the old layout is a good one. Put all the components together. All the hooks together. All the utilities together. It looks like a library. It looks organized.</p>
<p>It reads fine right up until you have more than a handful of features. Then the shared folders turn into drawers you&rsquo;re afraid to open. <code>utils/</code> has two hundred functions in it and you have no idea which screens use which. <code>hooks/</code> is the same. You want to change one helper and you can&rsquo;t tell what you&rsquo;ll break, because anything is allowed to import anything, and over a couple of years, everything did.</p>
<p>That&rsquo;s the real problem with grouping by type. It puts unrelated things next to each other and invites them to get tangled. A helper written for one screen gets quietly reused by three more. You change it for the first screen, and two screens you&rsquo;d never heard of break in a way nobody catches until a customer does.</p>
<p>Shared-by-default is how a codebase slowly ties itself in knots.</p>
<h2 id="one-folder-per-feature">One folder per feature</h2>
<p>So a couple of years ago I wrote it down as a decision. Stop organizing by type. Organize by feature.</p>
<p>Every feature gets one folder. Everything that feature needs lives inside it. Its components, its hooks, its API calls, its types, its helpers, its constants, its styles, its tests. All of it, in one place, named after the thing it actually is.</p>
<p>A feature folder looks like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>CustomHomePage/
</span></span><span style="display:flex;"><span>├── __tests__/                 // tests for this feature
</span></span><span style="display:flex;"><span>├── components/                // components used only here
</span></span><span style="display:flex;"><span>├── hooks/                     // hooks used only here
</span></span><span style="display:flex;"><span>├── layouts/                   // layouts specific to this feature
</span></span><span style="display:flex;"><span>├── api.ts                     // this feature&#39;s API calls
</span></span><span style="display:flex;"><span>├── index.ts                   // the feature&#39;s main export
</span></span><span style="display:flex;"><span>├── types.ts                   // this feature&#39;s types
</span></span><span style="display:flex;"><span>├── utils.ts                   // this feature&#39;s helpers
</span></span><span style="display:flex;"><span>├── constants.ts               // this feature&#39;s constants
</span></span><span style="display:flex;"><span>└── customHomePage.styles.ts   // styles for the main component
</span></span></code></pre></div><p>Now the question that started this post has a boring answer. Where does the hook go? In the feature folder. Where does the helper go? In the feature folder. The type, the style, the test? Feature folder. You stop standing at a fork, because there isn&rsquo;t one anymore. The default is always &ldquo;next to the thing that uses it.&rdquo;</p>
<p>And finding code gets just as boring. To understand the catalog, you open <code>views/Catalog</code> and it&rsquo;s all there. You don&rsquo;t go on a tour of six directories. The feature lives in one, and it&rsquo;s named after itself.</p>
<h2 id="shared-has-to-earn-it">Shared has to earn it</h2>
<p>The root-level folders didn&rsquo;t go away. We still have <code>api/</code>, <code>hooks/</code>, <code>utils/</code>, <code>stores/</code>, and the rest. They just changed jobs. They are now only for things that are genuinely used across more than one feature.</p>
<p>The rule we follow is small. Code starts local, inside its feature. It only moves up to a shared folder when a second feature actually needs it. Not when you have a hunch it might be reused someday. When it&rsquo;s really needed, by real code, in a second place. Sharing too early is how the old junk drawers filled up in the first place, so we made &ldquo;keep it local&rdquo; the lazy, default choice and &ldquo;make it shared&rdquo; the deliberate one.</p>
<p>There&rsquo;s a middle ground too, for the awkward case. Sometimes a bit of code is shared by a few parts of one big feature, but nothing outside it. That doesn&rsquo;t belong in the global folders, and it doesn&rsquo;t belong buried in one sub-part. So a feature can have its own <code>shared/</code> folder for exactly that. Our <code>CourseStore</code> and <code>Subscription</code> features both have one. It keeps the &ldquo;shared&rdquo; honest: local until a real neighbor needs it, and no higher up the tree than it has to be.</p>
<p>One more small rule that trips people up: a feature&rsquo;s own store lives with the feature, not in the root <code>stores/</code>. The root is for state the whole app leans on. Everything else stays home.</p>
<h2 id="a-convention-nobody-enforces-is-a-suggestion">A convention nobody enforces is a suggestion</h2>
<p>Here&rsquo;s the part I got wrong the first time.</p>
<p>You can write all of this down. You can put it in a document, give it a nice diagram, and present it to the team. And it will hold for about a month. Then someone in a hurry imports a helper from another feature because it was right there and it worked, and the reviewer is busy that day, and now there&rsquo;s one thread connecting two features that were supposed to be strangers. Do that fifty times and you&rsquo;re back where you started, except now there&rsquo;s a document claiming you&rsquo;re not.</p>
<p>A rule that only lives in a document is a suggestion. To actually keep the boundaries, they have to be checked by something that doesn&rsquo;t get tired or busy.</p>
<p>So we made the structure defend itself. A tool called <a href="https://github.com/sverweij/dependency-cruiser" target="_blank" rel="noopener noreferrer">dependency-cruiser</a>
 runs on every pull request, and it knows the boundaries as real rules:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>components   cannot import from   views
</span></span><span style="display:flex;"><span>hooks        cannot import from   views
</span></span><span style="display:flex;"><span>utils        cannot import from   views
</span></span><span style="display:flex;"><span>views/A      cannot import from   views/B
</span></span></code></pre></div><p>Shared code is not allowed to reach down into a feature. One feature is not allowed to reach sideways into another. There&rsquo;s a rule against circular imports for good measure. The moment someone crosses a line, the build stops and says no, right there in the pull request, before a human has to notice. The layout isn&rsquo;t a promise we keep by being careful. It&rsquo;s a fact the CI enforces whether we&rsquo;re careful or not.</p>
<h2 id="what-gets-easier-once-it-sticks">What gets easier once it sticks</h2>
<p>None of this is the point on its own. The point is what it makes ordinary.</p>
<p>Changing code stops being a gamble. When a helper lives inside the feature that uses it, you already know who depends on it: this feature, and nobody else. You can rewrite it, rename it, or delete it outright without first tracing every import across the tree. The blast radius of a change is the folder you&rsquo;re standing in, and you can see the whole folder at once.</p>
<p>And when something does break, you know where to look. A bug in the catalog lives in the catalog folder, because that&rsquo;s where the catalog&rsquo;s code lives. You&rsquo;re not bouncing between six directories trying to work out which shared helper got handed the wrong value three features away. The stack trace points at a folder, and the folder holds the whole story.</p>
<p>Deleting code gets easy, which almost never happens anywhere. When a feature is retired, you delete its folder and you&rsquo;re finished. There are no orphaned helpers left behind in <code>utils/</code>, no lonely hook that three screens still import for one line nobody understands. The feature arrived as one unit, and it leaves as one.</p>
<p>And two people can build at once without elbowing each other. Two features are two folders, so two engineers working on two things rarely touch the same files. The merge conflicts that used to bloom in the shared drawers, everyone editing the same <code>utils.ts</code> from opposite directions, mostly just stop.</p>
<h2 id="the-honest-tradeoffs">The honest tradeoffs</h2>
<p>This is not free, and pretending otherwise would be a lie.</p>
<p>You get some duplication. A tiny helper sometimes gets written twice in two features instead of shared once. That looks wasteful, and now and then it is. But a copied ten-line function is a much smaller problem than a shared one that secretly welds two features together. Duplication you can see. Coupling you can&rsquo;t.</p>
<p>You also get more folders and more files. Someone new to the codebase, looking at a feature with its own little <code>components</code> and <code>hooks</code> and <code>utils</code>, might feel it&rsquo;s a lot of ceremony for one screen. And there&rsquo;s a short adjustment period where people have to unlearn the reflex to drop everything in a global folder.</p>
<p>I&rsquo;ll take all of that. Those downsides are visible and shallow. The thing it prevents, a codebase quietly tying itself into a knot over three years, is the one that actually hurts.</p>
<h2 id="the-payoff-is-that-its-boring">The payoff is that it&rsquo;s boring</h2>
<p>The best thing I can say about this structure is that nothing interesting happens anymore.</p>
<p>A new engineer joins and, within a day, can guess where any piece of code lives, because there&rsquo;s one rule and it&rsquo;s the obvious one. A new AI coding assistant reads the repo and puts new files in the right place, because the right place is predictable. Code review stops including the phrase &ldquo;actually, can you move this.&rdquo; Where does this file go? It has exactly one answer, every time, and nobody has to think about it.</p>
<p>Put it next to the thing that uses it. Move it up only when something else truly needs it. Let the build enforce the rest.</p>
<p>That&rsquo;s the whole idea. It&rsquo;s not clever. It was never supposed to be.</p>
]]></content:encoded></item><item><title>One Instruction File for Every AI Agent</title><link>https://blog.talentlms.io/posts/one-file-every-robot/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0000</pubDate><dc:creator>Alexander Antoniades</dc:creator><guid>https://blog.talentlms.io/posts/one-file-every-robot/</guid><description>Three AI coding tools, three config files, all slowly disagreeing with each
other and with the code. This is how we got down to one source of truth the
robots and the humans both read.</description><enclosure url="https://blog.talentlms.io/images/posts/one-file-every-robot.jpg" type="image/png"/><media:content url="https://blog.talentlms.io/images/posts/one-file-every-robot.jpg" medium="image"><media:title type="plain">A single open book resting on a dark table with its pages fanned open, photographed against a soft, brightly lit blurred background.</media:title><media:description type="plain">A single open book resting on a dark table with its pages fanned open, photographed against a soft, brightly lit blurred background.</media:description></media:content><content:encoded><![CDATA[<img src="https://blog.talentlms.io/images/posts/one-file-every-robot.jpg" alt="A single open book resting on a dark table with its pages fanned open, photographed against a soft, brightly lit blurred background." style="max-width: 100%; height: auto; margin-bottom: 1.5em;" /><p style="margin-bottom: 1.5em; padding: 1em; background-color: #f5f5f5; border-left: 4px solid #0066cc;"><strong>Alexander Antoniades</strong>, Frontend Chapter Lead<br/>Alexander Antoniades is the Frontend Chapter Lead at TalentLMS. He spends his days obsessing over web performance and front-end architecture — the kind of work where shaving off 50 milliseconds feels …</p><p>The AI assistant handed me a component. Clean, typed, tests included. It also used an <code>interface</code> instead of a <code>type</code>, pulled a helper in through a relative <code>../../../utils</code> path, and hardcoded a user-facing string in English. Three of our conventions, broken in one file, and not a trace of doubt about any of it.</p>
<p>It wasn&rsquo;t the tool&rsquo;s fault. It had never read our rules. We had rules, plenty of them, but they lived in a wiki, and the tool had no way to reach them.</p>
<p>You can&rsquo;t expect a machine to follow conventions you keep somewhere it never looks.</p>
<h2 id="three-tools-three-files">Three tools, three files</h2>
<p>So we started writing the rules down where the AI would actually see them.</p>
<p>Claude Code reads a <code>CLAUDE.md</code> at the root of the repo. So we wrote one. It worked. The generated code started respecting our patterns because, for the first time, the patterns were sitting in the assistant&rsquo;s context instead of in a wiki.</p>
<p>Then people started using Cursor. Cursor doesn&rsquo;t read <code>CLAUDE.md</code>. It reads its own thing under <code>.cursor/</code>. So we wrote those rules too. Then Copilot arrived, and it reads <code>.github/copilot-instructions.md</code>. So we wrote a third copy.</p>
<p>Three files. The same conventions. Written out three separate times.</p>
<p>You already know how this ends. Someone tightens the testing convention in <code>CLAUDE.md</code>. The Cursor rules still describe the old way. The Copilot file has quietly become a fossil. Now the three tools disagree with each other, and all three disagree with the actual code. We had taken one documentation problem and turned it into three.</p>
<h2 id="one-file-symlinked">One file, symlinked</h2>
<p>The fix was almost embarrassing.</p>
<p>One file, and only one. We didn&rsquo;t even have to name it ourselves. There&rsquo;s an open format for exactly this called <code>AGENTS.md</code>, a plain-Markdown file that coding agents look for the way a person looks for a README. The big AI coding tools already agreed on it, so it wasn&rsquo;t something we had to invent. Using their shared standard beats making up our own.</p>
<p>So <code>AGENTS.md</code> became the one real file. For the tools that still expect their own filename, we pointed those names at it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>CLAUDE.md                        -&gt; AGENTS.md
</span></span><span style="display:flex;"><span>.github/copilot-instructions.md  -&gt; ../AGENTS.md
</span></span></code></pre></div><p>Symlinks. Claude Code opens <code>CLAUDE.md</code> and gets <code>AGENTS.md</code>. Copilot opens its file and gets <code>AGENTS.md</code>. One file to edit. No sync script, no CI job copying paragraphs from one place to another, no drift, because there is only one file and everything else is a pointer to it. The filesystem does the work for free.</p>
<h2 id="the-cursor-wrinkle">The Cursor wrinkle</h2>
<p>Cursor did not fall in line quite so neatly.</p>
<p>It doesn&rsquo;t load a single instructions file the way the others do. It reads small rule files under <code>.cursor/rules/</code>, each with a bit of frontmatter that can mark it as always applied. Fine. We can write those.</p>
<p>Here&rsquo;s the part that caught us out. Cursor injects the text of a rule into the model&rsquo;s context. It does not follow a link inside that rule and go read the file it points to. So a rule that helpfully said &ldquo;for testing conventions, see <code>.claude/skills/unit-test/SKILL.md</code>&rdquo; was worse than writing nothing. Cursor pasted that one sentence and stopped. The conventions, one link away, never loaded. The agent went off and did its own thing, and we assumed it had the rules, because we had, technically, written a rule.</p>
<p>A link is not a load. That sounds obvious written down. It is not obvious at all when you&rsquo;re staring at a rule file that clearly mentions the thing the agent is clearly ignoring.</p>
<p>So the <code>.mdc</code> files stopped trying to be the rules. They became triggers. Each one is short. A handful of the load-bearing bullets, and then an instruction aimed at the agent itself: before you write anything for this kind of task, go read this skill file. Not a summary pretending to be the whole thing. An order to go get the whole thing.</p>
<p>Here&rsquo;s what one of them actually looks like, the rule for writing tests:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-md" data-lang="md"><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span>description: Vitest + RTL test generation (integration-first; always applied)
</span></span><span style="display:flex;"><span>alwaysApply: true
</span></span><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># Tests (Vitest + RTL)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Co-locate <span style="color:#e6db74">`__tests__/{name}.test.jsx`</span> (components/hooks) or <span style="color:#e6db74">`.test.js`</span> (pure functions); camelCase filenames.
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Render via <span style="color:#e6db74">`@test-utils/render`</span>; Vitest globals without imports; no direct <span style="color:#e6db74">`@testing-library/react`</span>.
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Mock with <span style="color:#e6db74">`vi.mock()`</span>; test behavior and a11y, not implementation details.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="font-weight:bold">**Full detail:**</span> [<span style="color:#f92672">`.claude/skills/unit-test/SKILL.md`</span>](<span style="color:#a6e22e">../../.claude/skills/unit-test/SKILL.md</span>) — also load <span style="color:#e6db74">`references/*`</span> when generating tests.
</span></span></code></pre></div><p>The bullets are a nudge for the common case. The last line is the real instruction: the rest lives in the skill, go open it. The rule is deliberately too small to be the whole story, so it can&rsquo;t quietly rot into a stale half-copy of one.</p>
<h2 id="the-file-that-ate-itself">The file that ate itself</h2>
<p>While all this was happening, <code>AGENTS.md</code> developed a problem of its own. It was succeeding.</p>
<p>Every time we caught the AI getting a convention wrong, we added a section to fix it. Naming. Then styling. Then the API layer. Then testing, with examples. Then a list of anti-patterns. Then examples of the anti-patterns, so nobody could claim ambiguity.</p>
<p>It grew into something enormous. Hundreds of lines. And something predictable happens to a document that long. The agents started skimming it, the exact way you skim a terms-of-service page you have no intention of reading. The genuinely important rule about facades was sitting somewhere around paragraph forty, and by paragraph forty nobody was reading closely anymore. Not the humans, and not the agents we&rsquo;d written the whole thing for. It slowed every session down, too. Each one began by loading a small book before it got to do any actual work.</p>
<p>We had recreated the original problem in a new location. A pile of rules nobody reads. Same pile, better address.</p>
<p>So we turned it inside out. <code>AGENTS.md</code> stopped being the rules and became the index to the rules. It&rsquo;s about a hundred lines now, and most of that is a routing table. A topic on the left, the one file that owns it on the right.</p>
<p>A slice of it reads like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-md" data-lang="md"><span style="display:flex;"><span>| Topic                        | Canonical file                                         |
</span></span><span style="display:flex;"><span>| ---------------------------- | ------------------------------------------------------ |
</span></span><span style="display:flex;"><span>| React / TS / API / styling   | docs/guidelines-conventions/engineering-conventions.md |
</span></span><span style="display:flex;"><span>| Tests (Vitest + RTL)         | .claude/skills/unit-test/SKILL.md                      |
</span></span><span style="display:flex;"><span>| Naming                       | docs/guidelines-conventions/naming-conventions.md      |
</span></span><span style="display:flex;"><span>| Folder structure             | docs/ADR/00001-folder-structure.md                     |
</span></span><span style="display:flex;"><span>| PR / commits                 | docs/processes/CREATE_PULL_REQUEST.md                  |
</span></span></code></pre></div><p>Nothing in that file explains how to name a test or lay out a folder. It just knows which door to open. The discipline we hold ourselves to is a single line at the top: one topic, one file. Every convention has exactly one canonical home, and <code>AGENTS.md</code> only ever needs to know the address.</p>
<p>Now an agent reads a short index, finds the row that matches its task, and loads that one file. Not the book. The one chapter it needs for the thing it&rsquo;s about to do.</p>
<h2 id="the-wiki-was-the-real-problem">The wiki was the real problem</h2>
<p>There was still the matter of the wiki. All those original pages, the ones that kicked this off. For a while we treated them as the source and copied fragments into the repo, which, if you&rsquo;ve been paying attention, is the drift problem again. Just slower and more polite about it.</p>
<p>Documentation in a wiki rots for a plain reason. It sits nowhere near the code, and nobody reviews it. You change how testing works inside a pull request, where two people look at the diff and argue about it. The wiki page describing how testing works just sits there and ages, until a new hire follows it to the letter and ends up confused in a channel asking why nothing matches.</p>
<p>So the pages are moving into the repo. Each convention becomes a file that lives next to the code it describes. Changing one means opening a pull request, which means someone reviews it, the same as any other change we make. The old Confluence pages now carry a banner across the top telling you they&rsquo;re being migrated and to make your edits in the repo, not there.</p>
<p>And the AI reads all of it at no extra effort, because it&rsquo;s finally sitting in the place the AI was already looking.</p>
<h2 id="what-we-took-from-it">What we took from it</h2>
<ul>
<li>One canonical file, symlinked to whatever names your tools insist on. Let the filesystem prevent drift instead of trusting a script and a good mood.</li>
<li>Learn how each tool actually loads context before you write for it. Cursor pastes a rule and stops. A link is not a load. Write triggers that send the agent to read the source, not summaries that quietly pretend to be it.</li>
<li>A rules file long enough to be complete is long enough to be ignored. Index, don&rsquo;t inline. One topic, one file, one home.</li>
<li>Put the docs where the work is. Conventions in a wiki are conventions on their way out. In the repo they get versioned, reviewed, and actually read.</li>
</ul>
<p>The thing that caught me off guard was who ended up benefiting. We did all of this for the robots. But a convention that&rsquo;s short, findable, kept in the repo, and reviewed in a pull request turns out to be a better convention for the people too. The new engineer and the new AI assistant now start from the same page, literally.</p>
<p>The AI never needed special treatment. It needed what everyone always needed and rarely got. The rules, written down once, kept current, and left somewhere you&rsquo;d actually think to look.</p>
]]></content:encoded></item><item><title>Whose Bug Is It Anyway? Teaching Our Error Boundaries to Name a Team</title><link>https://blog.talentlms.io/posts/whose-bug-is-it-anyway/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0000</pubDate><dc:creator>Alexander Antoniades</dc:creator><guid>https://blog.talentlms.io/posts/whose-bug-is-it-anyway/</guid><description>A React app crashes. Sentry tells you it crashed. It does not tell you whose
crash it is. You can usually work that out. It's just a step nobody should
have to do by hand.
This is the story of how we taught our error boundaries to tag the team that
owns the screen, so the report answers that question on its own.</description><enclosure url="https://blog.talentlms.io/images/posts/whose-bug-is-it-anyway.jpg" type="image/png"/><media:content url="https://blog.talentlms.io/images/posts/whose-bug-is-it-anyway.jpg" medium="image"><media:title type="plain">A dim nighttime desk with a laptop and monitor displaying code, lit by blue and red ambient light, a notebook of hand-drawn diagrams beside the keyboard.</media:title><media:description type="plain">A dim nighttime desk with a laptop and monitor displaying code, lit by blue and red ambient light, a notebook of hand-drawn diagrams beside the keyboard.</media:description></media:content><content:encoded><![CDATA[<img src="https://blog.talentlms.io/images/posts/whose-bug-is-it-anyway.jpg" alt="A dim nighttime desk with a laptop and monitor displaying code, lit by blue and red ambient light, a notebook of hand-drawn diagrams beside the keyboard." style="max-width: 100%; height: auto; margin-bottom: 1.5em;" /><p style="margin-bottom: 1.5em; padding: 1em; background-color: #f5f5f5; border-left: 4px solid #0066cc;"><strong>Alexander Antoniades</strong>, Frontend Chapter Lead<br/>Alexander Antoniades is the Frontend Chapter Lead at TalentLMS. He spends his days obsessing over web performance and front-end architecture — the kind of work where shaving off 50 milliseconds feels …</p><p>It&rsquo;s a Monday. You open Sentry before you open anything else, because that&rsquo;s the job now. There&rsquo;s a new issue at the top. A white screen, a component that threw, a stack trace that&rsquo;s been minified into hieroglyphics. The error message is <code>undefined is not an object</code>. Helpful.</p>
<p>You stare at it. The real question isn&rsquo;t <em>what</em> broke. React already told you what broke. The question is <em>whose</em> it is.</p>
<p>Usually you can answer it. Read the stack trace, recognize a component, match it to a team. Most of the time that&rsquo;s a minute of work. Occasionally you guess wrong, ask in a channel, and someone points you the right way.</p>
<p>None of that is hard. It&rsquo;s just manual. A small tax you pay every time an error comes in, on a question the app already had the answer to and simply never wrote down.</p>
<h2 id="the-boundary-that-caught-everything-and-told-you-nothing">The boundary that caught everything and told you nothing</h2>
<p>We weren&rsquo;t reckless. We had error boundaries. We&rsquo;d done the React thing. Wrap the app, catch the throw, show a polite fallback instead of a blank white void.</p>
<p>The problem is what &ldquo;the React thing&rdquo; looked like in practice. One boundary, sitting at the very top of the tree, catching everything. A single net under the entire trapeze act.</p>
<p>It did its job. The app didn&rsquo;t show users a stack trace. It showed them a tasteful &ldquo;something went wrong&rdquo; screen instead. That part worked.</p>
<p>But from a triage perspective, every error in the building arrived wearing the same uniform. A crash in the course player and a crash in the billing settings and a crash in a dashboard widget all showed up in Sentry looking identical. Same boundary. Same fallback. Same shrug.</p>
<p>We had observability into <em>that</em> something went wrong. We had nothing into <em>who should care</em>.</p>
<p>In a company with more than one team, errors aren&rsquo;t homeless. Every screen belongs to someone. The course player is Courses. Billing is Business. Integrations is, well, Integrations. The information existed. It lived in people&rsquo;s heads and in the org chart and in the routing table. It just never made it into the error report.</p>
<p>So we kept answering, by hand, a question the code could have answered for us.</p>
<h2 id="the-fix-is-not-catch-more-errors">The fix is not &ldquo;catch more errors&rdquo;</h2>
<p>When the team finally sat down to fix this, the first instinct was wrong. Mine included. We assumed it was a coverage problem. More boundaries. Smaller boundaries. Catch errors closer to where they happen.</p>
<p>That&rsquo;s part of it. But it&rsquo;s not the part that mattered.</p>
<p>The part that mattered was attribution. An error report is only as useful as the question it answers, and the question we cared about was ownership. We didn&rsquo;t need to catch errors better. We needed every error to walk into Sentry already knowing its team.</p>
<p>Sentry has a primitive for exactly this. Tags. You can attach arbitrary key-value pairs to an event, then filter and alert on them later. <code>team:courses</code>. <code>team:business</code>. Suddenly the dashboard isn&rsquo;t a pile of anonymous stack traces. It&rsquo;s filterable by the people who can actually fix things.</p>
<p>The only question left was <em>where</em> to attach the tag. And that question turned out to be more interesting than it sounds.</p>
<h2 id="tag-the-route-not-the-component">Tag the route, not the component</h2>
<p>The obvious place to put a team tag is inside the component. The course player knows it&rsquo;s the course player. Just have it announce itself.</p>
<p>Good thing we didn&rsquo;t.</p>
<p>Because component ownership is a lie that drifts. Teams get renamed. Responsibilities shift. A view that belonged to Courses in January can become Content’s responsibility by June as team boundaries and ownership evolve over time. If the team identity is sprinkled across a hundred components, updating it means a hundred edits and a prayer that you found them all.</p>
<p>The routing table doesn&rsquo;t drift like that. It&rsquo;s the one place in the app where &ldquo;this screen exists, and here&rsquo;s how you reach it&rdquo; is written down on purpose. If you want a single source of truth for <em>who owns what screen</em>, the route definitions are already it. You&rsquo;re just not using them that way yet.</p>
<p>So that&rsquo;s where the tag goes. We wrote a small higher-order component that wraps a view in a Sentry error boundary and stamps the team onto the error before it&rsquo;s ever sent:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-tsx" data-lang="tsx"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">withSentryBoundary</span> <span style="color:#f92672">=</span> &lt;<span style="color:#f92672">P</span> <span style="color:#a6e22e">extends</span> <span style="color:#a6e22e">object</span>&gt;(
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">Component</span>: <span style="color:#66d9ef">React.ComponentType</span>&lt;<span style="color:#f92672">P</span>&gt;,
</span></span><span style="display:flex;"><span>  { <span style="color:#a6e22e">fallback</span> <span style="color:#f92672">=</span> &lt;<span style="color:#f92672">FallbackErrorScreen</span> /&gt;, <span style="color:#a6e22e">tags</span> <span style="color:#f92672">=</span> {} }<span style="color:#f92672">:</span> <span style="color:#a6e22e">WithSentryBoundaryOptions</span>,
</span></span><span style="display:flex;"><span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">React</span>.<span style="color:#a6e22e">FC</span>&lt;<span style="color:#f92672">P</span>&gt; <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">WrappedComponent</span>: <span style="color:#66d9ef">React.FC</span>&lt;<span style="color:#f92672">P</span>&gt; <span style="color:#f92672">=</span> (<span style="color:#a6e22e">props</span>) <span style="color:#f92672">=&gt;</span> (
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#f92672">ErrorBoundary</span>
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">fallback</span><span style="color:#f92672">=</span>{<span style="color:#a6e22e">fallback</span>}
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">beforeCapture</span><span style="color:#f92672">=</span>{(<span style="color:#a6e22e">scope</span>: <span style="color:#66d9ef">Scope</span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>        Object.<span style="color:#a6e22e">entries</span>(<span style="color:#a6e22e">tags</span>).<span style="color:#a6e22e">forEach</span>(([<span style="color:#a6e22e">key</span>, <span style="color:#a6e22e">value</span>]) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>          <span style="color:#a6e22e">scope</span>.<span style="color:#a6e22e">setTag</span>(<span style="color:#a6e22e">key</span>, <span style="color:#a6e22e">value</span>);
</span></span><span style="display:flex;"><span>        });
</span></span><span style="display:flex;"><span>      }}
</span></span><span style="display:flex;"><span>    &gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#f92672">Component</span> {<span style="color:#a6e22e">...props</span>} /&gt;
</span></span><span style="display:flex;"><span>    &lt;/<span style="color:#f92672">ErrorBoundary</span>&gt;
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">WrappedComponent</span>.<span style="color:#a6e22e">displayName</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`withSentryBoundary(</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">Component</span>.<span style="color:#a6e22e">name</span><span style="color:#e6db74">}</span><span style="color:#e6db74">)`</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">WrappedComponent</span>;
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>The important word there is <code>beforeCapture</code>. It&rsquo;s a hook Sentry gives you that runs on the boundary&rsquo;s own scope <em>before</em> the error event leaves the building. So by the time the crash is reported, the team tag is already on it. Not added later. Not inferred. Already there, attached at the moment of capture.</p>
<p>And then in the routing table, each view declares its owner exactly once, right where it&rsquo;s wired up:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-tsx" data-lang="tsx"><span style="display:flex;"><span><span style="color:#a6e22e">withSentryBoundary</span>(<span style="color:#a6e22e">Catalog</span>,   { <span style="color:#a6e22e">tags</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">team</span>: <span style="color:#66d9ef">Teams.COURSES</span> } })
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">withSentryBoundary</span>(<span style="color:#a6e22e">Dashboard</span>, { <span style="color:#a6e22e">tags</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">team</span>: <span style="color:#66d9ef">Teams.BUSINESS</span> } })
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">withSentryBoundary</span>(<span style="color:#a6e22e">Automations</span>, { <span style="color:#a6e22e">tags</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">team</span>: <span style="color:#66d9ef">Teams.INTEGRATIONS</span> } })
</span></span></code></pre></div><p>The teams themselves live in one tiny file, so nobody invents a typo&rsquo;d team name at two in the morning:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">Teams</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">BUSINESS</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;business&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">CORE</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;core&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">COURSES</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;courses&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">CONTENT</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;content&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">INTEGRATIONS</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;integrations&#34;</span>,
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">as</span> <span style="color:#66d9ef">const</span>;
</span></span></code></pre></div><p>That&rsquo;s the whole trick. The error doesn&rsquo;t know what team it belongs to. The route does. And the route is the thing least likely to lie to you.</p>
<h2 id="three-nets-not-one">Three nets, not one</h2>
<p>Once the tagging worked, the coverage question came back. This time we knew why we were answering it. Different crashes deserve different blast radii.</p>
<p>So we ended up with three layers, and they&rsquo;re layered on purpose.</p>
<p>At the very top, in the app&rsquo;s entry point, there&rsquo;s still one boundary catching everything. The last line of defense. If something escapes every other net, this one stops the user from staring at a blank document, and shows a full-page fallback instead.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-tsx" data-lang="tsx"><span style="display:flex;"><span>&lt;<span style="color:#f92672">ErrorBoundary</span> <span style="color:#a6e22e">fallback</span><span style="color:#f92672">=</span>{&lt;<span style="color:#f92672">FallbackErrorScreen</span> <span style="color:#a6e22e">isFullPage</span> /&gt;}&gt;
</span></span><span style="display:flex;"><span>  {<span style="color:#75715e">/* the entire application */</span>}
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">ErrorBoundary</span>&gt;
</span></span></code></pre></div><p>This boundary should almost never fire. When it does, it means something got past everything else, and that&rsquo;s worth knowing on its own.</p>
<p>The middle layer is the route level. The <code>withSentryBoundary</code> wrappers from earlier, one per view, each carrying its team tag. This is where most real crashes get caught, named, and contained. A view blowing up takes down its own screen and reports itself to the right team. The rest of the app keeps running.</p>
<p>There&rsquo;s a small detail in our dashboard layout that I&rsquo;m quietly fond of. The route boundary is keyed by the current path:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-tsx" data-lang="tsx"><span style="display:flex;"><span>&lt;<span style="color:#f92672">ErrorBoundary</span> <span style="color:#a6e22e">fallback</span><span style="color:#f92672">=</span>{&lt;<span style="color:#f92672">FallbackErrorScreen</span> /&gt;} <span style="color:#a6e22e">key</span><span style="color:#f92672">=</span>{<span style="color:#a6e22e">getErrorBoundaryKey</span>(<span style="color:#a6e22e">pathname</span>)}&gt;
</span></span><span style="display:flex;"><span>  {<span style="color:#75715e">/* the routed view */</span>}
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">ErrorBoundary</span>&gt;
</span></span></code></pre></div><p>React remounts a component when its <code>key</code> changes. So when a user lands on a crashed screen, sees the fallback, and clicks away to a different view, the key changes, the boundary remounts, and the error state clears itself. The user navigates out of the wreckage instead of being trapped in it until a hard refresh. Crash recovery, for free, from a prop React already had.</p>
<p>The third layer is the smallest, and it&rsquo;s where this stops being theory. Our dashboard is a grid of widgets. Each one fetches its own data, lazy-loads its own code, and is fully capable of throwing on its own. If a single misbehaving widget could take down the whole dashboard, the page would be one bad API response away from a white screen.</p>
<p>So every widget gets its own boundary, wrapped right around its <code>Suspense</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-tsx" data-lang="tsx"><span style="display:flex;"><span>&lt;<span style="color:#f92672">ErrorBoundary</span>
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">fallback</span><span style="color:#f92672">=</span>{&lt;<span style="color:#f92672">ErrorBoundaryScreen</span> /&gt;}
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">beforeCapture</span><span style="color:#f92672">=</span>{(<span style="color:#a6e22e">scope</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">scope</span>.<span style="color:#a6e22e">setTags</span>({ <span style="color:#a6e22e">team</span>: <span style="color:#66d9ef">Teams.BUSINESS</span> })}
</span></span><span style="display:flex;"><span>&gt;
</span></span><span style="display:flex;"><span>  &lt;<span style="color:#f92672">Suspense</span>&gt;
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#f92672">Component</span> {<span style="color:#a6e22e">...widgetProps</span>} /&gt;
</span></span><span style="display:flex;"><span>  &lt;/<span style="color:#f92672">Suspense</span>&gt;
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">ErrorBoundary</span>&gt;
</span></span></code></pre></div><p>One widget dies, you get a tidy little &ldquo;couldn&rsquo;t load this widget&rdquo; card in its place. The other eleven widgets don&rsquo;t even notice. The dashboard stays a dashboard.</p>
<p>Top of the app, you want the net wide and the failure loud. Down at a single widget, you want the net tiny and the failure invisible to everything around it. Same primitive, three sizes, chosen by how much should be allowed to break.</p>
<h2 id="a-tag-is-only-worth-what-the-inbox-is-worth">A tag is only worth what the inbox is worth</h2>
<p>There&rsquo;s a part of this that&rsquo;s easy to skip and shouldn&rsquo;t be.</p>
<p>A team tag is only useful if <code>team:courses</code> actually means &ldquo;Courses has a bug to fix.&rdquo; The moment Sentry fills up with noise, the tags stop meaning anything. A user&rsquo;s flaky wifi. A browser extension throwing inside your bundle. A 404 from an endpoint that was always going to 404. People start ignoring the channel. And an alert everyone ignores is worse than no alert, because it cost you the effort of building it and gave you nothing back.</p>
<p>So we spend real effort on the front door. Before an error is ever sent, it goes through a filter. Connectivity blips get dropped. The framework&rsquo;s own known-noisy errors get dropped. Client-side 4xx responses get dropped too. The user asked for something they weren&rsquo;t allowed to have. That&rsquo;s not a crash, that&rsquo;s Tuesday.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#a6e22e">beforeSend</span>(<span style="color:#a6e22e">event</span>, <span style="color:#a6e22e">hint</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isFramelessStackOverflow</span>(<span style="color:#a6e22e">event</span>)) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">status</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">getHttpStatus</span>(<span style="color:#a6e22e">hint</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">originalException</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">typeof</span> <span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;number&#34;</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">status</span> <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">400</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">status</span> <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">500</span>) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">sanitizeErrorEvent</span>(<span style="color:#a6e22e">event</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Returning <code>null</code> means the event never leaves. It&rsquo;s a small function with an outsized job. It&rsquo;s the reason the team tags point at real work instead of pointing at static.</p>
<p>The tagging tells you whose bug it is. The filtering is what makes &ldquo;whose bug&rdquo; a question worth answering at all.</p>
<h2 id="what-it-actually-bought-us">What it actually bought us</h2>
<p>One more piece of honesty. We didn&rsquo;t build this from scratch in a vacuum. We&rsquo;d been leaning on <code>react-error-boundary</code>, the community library, and at some point realized that <code>@sentry/react</code> ships its own <code>ErrorBoundary</code> that already knows how to talk to Sentry&rsquo;s scope. The <code>beforeCapture</code> hook, the scope, the tagging. All native. So we migrated onto it. One fewer dependency, and a boundary that speaks the language of the tool we were already paying for.</p>
<p>The payoff isn&rsquo;t dramatic in the way a shiny feature is dramatic. Nobody sends you a thank-you note for an error report that routes itself. But the morning ritual changed. The new issue at the top of Sentry now arrives wearing a team name. You read the tag, you know if it&rsquo;s yours, and if it isn&rsquo;t, you move on with your coffee still warm.</p>
<p>The things I&rsquo;d take from it, if you&rsquo;re staring at your own pile of anonymous stack traces:</p>
<ul>
<li><strong>Catching the error is the easy half. Attributing it is the half you keep doing by hand.</strong> It&rsquo;s rarely hard, just repetitive, and repetitive is exactly what you should hand to the code. Optimize for the question you actually ask at triage: &ldquo;whose is this?&rdquo;</li>
<li><strong>Put ownership where it can&rsquo;t drift.</strong> The routing table already knows which screens exist. Let it be the one place that knows who owns them, too.</li>
<li><strong>Match the boundary to the blast radius.</strong> Wide and loud at the top, tiny and silent around a widget. One primitive, sized to how much is allowed to break.</li>
<li><strong>Guard the inbox like it&rsquo;s the product.</strong> A tag pointed at noise is worse than no tag. Drop the connectivity blips and the 4xxs before they ever land.</li>
</ul>
<p>A white screen will always be a bad day for somebody. We didn&rsquo;t fix that. We just made sure the report knows whose bad day it is, and that it shows up before they do.</p>
]]></content:encoded></item><item><title>Upgrading from React 18 to React 19: What We Learned and How Claude Code Saved What Was Left of My Sanity</title><link>https://blog.talentlms.io/posts/upgrading-from-react-18-to-react-19/</link><pubDate>Mon, 30 Mar 2026 00:00:00 +0000</pubDate><dc:creator>Alexander Antoniades</dc:creator><guid>https://blog.talentlms.io/posts/upgrading-from-react-18-to-react-19/</guid><description>You bump a version, run install, and the project catches fire. That's a React upgrade.
Half the bugs you find were already there — the rest is types, tests, and Claude Code keeping the investigation from eating you alive.</description><enclosure url="https://blog.talentlms.io/images/posts/upgrading-from-react-18-to-react-19.jpg" type="image/png"/><media:content url="https://blog.talentlms.io/images/posts/upgrading-from-react-18-to-react-19.jpg" medium="image"><media:title type="plain">Laptop screen displaying the React logo in a dim workspace, suggesting a focused frontend upgrade.</media:title><media:description type="plain">Laptop screen displaying the React logo in a dim workspace, suggesting a focused frontend upgrade.</media:description></media:content><content:encoded><![CDATA[<img src="https://blog.talentlms.io/images/posts/upgrading-from-react-18-to-react-19.jpg" alt="Laptop screen displaying the React logo in a dim workspace, suggesting a focused frontend upgrade." style="max-width: 100%; height: auto; margin-bottom: 1.5em;" /><p style="margin-bottom: 1.5em; padding: 1em; background-color: #f5f5f5; border-left: 4px solid #0066cc;"><strong>Alexander Antoniades</strong>, Frontend Chapter Lead<br/>Alexander Antoniades is the Frontend Chapter Lead at TalentLMS. He spends his days obsessing over web performance and front-end architecture — the kind of work where shaving off 50 milliseconds feels …</p><p>I want you to picture something. It&rsquo;s a Tuesday. You&rsquo;re at your desk. Coffee&rsquo;s still warm. You open your terminal, bump a version number, hit install, and watch your entire project catch fire.</p>
<p>That&rsquo;s a React upgrade.</p>
<p>It always <em>sounds</em> straightforward — a quick version bump, <code>npm install</code>, maybe mute a few warnings. But if you&rsquo;ve actually done it, you know the truth. The version number is the easy part. Everything that comes after is the part nobody warns you about.</p>
<p>A few months back, our Frontend chapter pulled off a full upgrade from React 17 to React 18 during a hackathon at our headquarters in Athens. The whole team, working toward a common goal. It was challenging, messy, and — I&rsquo;ll admit — actually fun.</p>
<p>React 18 was working fine after that. Life was good. But the ecosystem, it never sits still, does it? Libraries started hinting that React 19 was the &ldquo;expected&rdquo; baseline. TypeScript types were drifting in that direction. Deprecation warnings started showing up like passive-aggressive Post-it notes on your monitor.</p>
<p>Nobody wants to be the team still running the old version when the next wave hits. That&rsquo;s how you end up three versions behind, staring down a rewrite.</p>
<p>So this time, I decided to do it solo. Just me, a codebase, and what I assumed would be a quiet afternoon.</p>
<p>I was wrong.</p>
<hr>
<h2 id="the-challenges">The Challenges</h2>
<h3 id="dependency-conflicts">Dependency Conflicts</h3>
<p>Here&rsquo;s the thing about upgrading React. You&rsquo;re not upgrading React. You&rsquo;re upgrading <em>everything that ever touched React</em>.</p>
<p>The moment I bumped to React 19, the whole project started screaming. React Router needed a version bump. Nearly all unit tests failed. Testing libraries demanded newer React DOM APIs. ESLint plugins lost their minds. I thought this would be easier than the 17-to-18 jump. Instead, I was playing whack-a-mole with a cascade of small fires, each one revealing two more underneath.</p>
<p>I thought about quitting. Just reverting the branch, closing the laptop, going for a walk. Pretending none of it happened.</p>
<p>I didn&rsquo;t. But I thought about it.</p>
<h3 id="the-bugs-that-were-always-there">The Bugs That Were Always There</h3>
<p>Here&rsquo;s a dirty secret about React upgrades: half the bugs you &ldquo;discover&rdquo; aren&rsquo;t new. They were always there. You just never had a reason to look.</p>
<p>Take this example. On React 18, this effect worked without complaints — <code>reset</code> was replacing the entire form state with just <code>{ event: { type } }</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#a6e22e">useEffect</span>(() <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">preservedEventType</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">formValuesWatch</span>.<span style="color:#a6e22e">event</span><span style="color:#f92672">?</span>.<span style="color:#66d9ef">type</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">reset</span>({ <span style="color:#a6e22e">event</span><span style="color:#f92672">:</span> { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">preservedEventType</span> } });
</span></span><span style="display:flex;"><span>}, [<span style="color:#a6e22e">eventTypeWatch</span>]);
</span></span></code></pre></div><p>It worked. It shipped. Nobody questioned it. But it was broken the whole time. Every time this effect ran, it was throwing away the rest of your form values and replacing everything with a minimal object. React 18&rsquo;s StrictMode was already double-firing effects in development — which meant this bug was being triggered twice per render cycle. But TypeScript didn&rsquo;t complain about the shape mismatch, and the form <em>seemed</em> to work, so nobody noticed. Or maybe we just looked the other way.</p>
<p>Then we upgraded to React 19 and updated <code>@types/react</code>. The stricter types lit up like a Christmas tree. Suddenly TypeScript cared about what we were passing to <code>reset()</code>. And once we started looking at the flagged code, the real bug became obvious — this effect had been silently corrupting form state all along. The upgrade didn&rsquo;t break it. It just ripped off the bandage.</p>
<p>The fix was to stop being lazy about it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#a6e22e">useEffect</span>(() <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">preservedEventType</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">formValuesWatch</span>.<span style="color:#a6e22e">event</span><span style="color:#f92672">?</span>.<span style="color:#66d9ef">type</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">currentValues</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">getValues</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">reset</span>({
</span></span><span style="display:flex;"><span>    ...<span style="color:#a6e22e">currentValues</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">event</span><span style="color:#f92672">:</span> { ...<span style="color:#a6e22e">currentValues</span>.<span style="color:#a6e22e">event</span>, <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">preservedEventType</span> },
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}, [<span style="color:#a6e22e">eventTypeWatch</span>]);
</span></span></code></pre></div><p>Grab the current values. Spread them. Override only what you need. Don&rsquo;t blow away things you didn&rsquo;t mean to touch. Simple, once you see it. But you have to see it first — and sometimes it takes an upgrade to force you to look.</p>
<h3 id="typescript-got-stricter-as-well">TypeScript Got Stricter As Well</h3>
<p>This is the part where it gets fun. And by fun, I mean the kind of fun where you open your IDE and every single file has red squiggles. Everywhere. Like your codebase developed a rash overnight.</p>
<p>React 19 shipped with completely overhauled TypeScript types, and the changes were not cosmetic. <code>ReactChild</code>, <code>ReactText</code>, a handful of other utility types you probably used without thinking — gone. Just removed. If your codebase referenced them, even indirectly through a shared component library, you got a wall of errors the moment you updated <code>@types/react</code>.</p>
<p>Then there was the <code>ref</code> situation. React 19 finally lets you pass <code>ref</code> as a regular prop. No more <code>forwardRef</code> wrapping. That&rsquo;s the good news. The bad news is that on the TypeScript side, ref callback return types got stricter. If you had ref callbacks with implicit returns — an arrow function that accidentally returned something — TypeScript now rejected them. Across a large codebase, these &ldquo;small&rdquo; changes multiplied into hundreds of type errors.</p>
<p><code>useRef</code> got pickier too. Call it without an argument? TypeScript error. You need to explicitly pass <code>null</code> or <code>undefined</code> now. <code>RefObject</code> no longer includes <code>null</code> by default, so any code that assumed <code>ref.current</code> could be <code>null</code> needed touching.</p>
<p>The React team published a codemod (<code>types-react-codemod</code>) that handled some of this. The keyword being <em>some</em>. The more custom your type patterns, the more you were on your own.</p>
<h3 id="the-forwardref-farewell">The <code>forwardRef</code> Farewell</h3>
<p>This one deserves its own section because it touched <em>everything</em>.</p>
<p>With React 19 treating <code>ref</code> as a regular prop, <code>forwardRef</code> becomes unnecessary. In theory, that&rsquo;s a beautiful simplification. In practice, we had dozens of components wrapped in <code>forwardRef</code>, each with its own generic type signatures, its own quirks, its own reasons for existing.</p>
<p>Removing <code>forwardRef</code> meant restructuring every single one. Pull <code>ref</code> into the props interface. Update the type signatures. Make sure the parent components that pass refs still compile. It was mechanical, repetitive, mind-numbing work — the kind where you get sloppy on component number 47 because your eyes have gone glassy and everything looks the same.</p>
<p>The kind of work that breaks you if you let it.</p>
<hr>
<h2 id="enter-claude-code">Enter Claude Code</h2>
<p>A few hours in. Buried in type errors. Tests failing everywhere. That quiet voice in your head saying <em>this solo upgrade was a terrible idea</em>.</p>
<p>That&rsquo;s when I decided to throw Claude Code at it.</p>
<p>I&rsquo;d used it before for smaller things — generating boilerplate, explaining someone else&rsquo;s code, the usual. But I&rsquo;d never thrown it at a full-scale migration. A real mess. The kind of mess where you don&rsquo;t even know where to start.</p>
<p>Turns out, that&rsquo;s exactly where it comes alive.</p>
<h3 id="bulk-code-migrations">Bulk Code Migrations</h3>
<p>The <code>forwardRef</code> removal was the first real test. Dozens of components. All needing the same structural surgery: unwrap <code>forwardRef</code>, move <code>ref</code> into the props type, clean up the generics. By hand, this is the kind of task that takes hours and introduces bugs at a steady, reliable rate.</p>
<p>With Claude Code, I described the pattern once. It understood the structure. And then it just&hellip; did it. Across the codebase.</p>
<p>What got me was how it adapted. It didn&rsquo;t do a dumb find-and-replace. Components with complex generic types got different treatment than simple ones. It caught cases where <code>ref</code> was being used in non-standard ways and flagged them for manual review instead of blindly transforming them. It was thinking about the code, not just processing it.</p>
<p>Same approach worked for the deprecated type references. Every instance of <code>ReactChild</code> that needed to become <code>React.ReactElement | number | string</code>? Handled. File by file. Respecting existing imports. Not introducing noise.</p>
<h3 id="debugging-type-errors">Debugging Type Errors</h3>
<p>This is where Claude Code really earned its place. After the bulk migrations, I still had a backlog of type errors that weren&rsquo;t mechanical. Nuanced things — <code>useReducer</code> inference changes, ref callback return types, third-party library type mismatches.</p>
<p>Normally, each of these is a 20-minute rabbit hole. Google the error. Find a GitHub issue from eight months ago. Read through 40 comments to find the one that actually has the answer. Try three different approaches. Repeat.</p>
<p>Instead, I could feed Claude Code the error and the surrounding code and get back a clear explanation of <em>why</em> it broke and <em>how</em> to fix it. Not just &ldquo;change this line.&rdquo; It understood the difference between &ldquo;this broke because React 19 changed the type definition&rdquo; and &ldquo;this was always wrong, React 18 just let you get away with it.&rdquo;</p>
<p>One that sticks out: we had a custom hook that returned a ref callback. After the upgrade, TypeScript hated the return type. Claude Code immediately identified the issue — React 19 introduced ref cleanup functions, so you can now return a cleanup function from a ref callback, similar to <code>useEffect</code>. The implicit return in our arrow function was being interpreted as a cleanup function. The fix was a one-liner. Add explicit curly braces. Done.</p>
<p>Understanding <em>why</em> that was the fix? That would have cost me half an hour on my own. Maybe more.</p>
<h3 id="fixing-broken-tests">Fixing Broken Tests</h3>
<p>Nearly all our unit tests were failing. This was probably the most daunting part, because test failures are a special kind of demoralizing — you can&rsquo;t tell at a glance whether it&rsquo;s a real problem or just noise from the upgrade.</p>
<p>The failures fell into categories. Testing library API changes. StrictMode behavior affecting test output. Genuine regressions from our refactoring. The tricky part is figuring out <em>which is which</em>.</p>
<p>Claude Code helped me triage at speed. Feed it a failing test with its error output. It would tell me: this is a testing library compatibility issue (update <code>@testing-library/react</code>, adjust the assertions), this is a StrictMode double-render thing (your test was relying on render count), this is an actual bug you introduced (fix it).</p>
<p>For the testing library issues, it helped me batch-update patterns across the entire test suite. Replacing deprecated query methods. Updating async utilities. Adjusting mock setups that assumed React 18 rendering behavior. The boring, necessary work that eats your day if you do it by hand.</p>
<hr>
<h2 id="what-i-learned">What I Learned</h2>
<p>A few things I&rsquo;m taking away from this:</p>
<p><strong>Upgrade to 18.3 first.</strong> If you haven&rsquo;t already, install <code>react@18.3</code> before you even think about 19. It&rsquo;s identical to 18.2 but adds deprecation warnings for everything that will break. Think of it as a damage report before the actual damage.</p>
<p><strong>Don&rsquo;t underestimate the type changes.</strong> The runtime breaking changes in React 19? Manageable. The TypeScript type changes? That&rsquo;s where the real volume lives, especially in a large codebase. Run the official <code>types-react-codemod</code> early — it won&rsquo;t catch everything, but it&rsquo;ll clear the surface-level wreckage. And if you&rsquo;re on a large project and can&rsquo;t afford to fix every type error in one go, look into <a href="https://phenomnomnominal.github.io/betterer/" target="_blank" rel="noopener noreferrer">Betterer</a>
. It lets you snapshot your current type errors and enforce that things only get <em>better</em> over time — no new errors allowed, but existing ones can be chipped away incrementally across PRs. Merge the upgrade, unblock your team, and clean up at your own pace. No shame in that.</p>
<p><strong>The upgrade didn&rsquo;t break your code. It exposed what was already broken.</strong> Half the bugs we found during the migration existed in React 18 too — we just never had a reason to look. Stricter types forced us to revisit code we hadn&rsquo;t touched in months, and that&rsquo;s where the real issues were hiding. Don&rsquo;t blame the upgrade. Thank it.</p>
<p><strong>AI tooling has crossed a line for migrations.</strong> I&rsquo;ll be honest — I was skeptical. Migrations aren&rsquo;t about writing new code. They&rsquo;re about understanding why existing code breaks under new rules and applying the right fix without creating new problems. That felt too nuanced for an AI tool. I was wrong. Claude Code didn&rsquo;t replace my judgment, but it cut the mechanical work and research time dramatically. The ratio shifted from &ldquo;90% investigation, 10% fixing&rdquo; to something much more sane.</p>
<hr>
<h2 id="final-thoughts">Final Thoughts</h2>
<p>Would I do the solo upgrade again? Yes. But only because Claude Code made it possible. Without it, this would have been days of documentation, GitHub issues, and Stack Overflow threads. The kind of work that makes you question your career choices.</p>
<p>Instead, it was one intense session where I could focus on the decisions that actually mattered and hand off the repetitive, soul-crushing investigation work to something that didn&rsquo;t mind doing it.</p>
<p>The React ecosystem keeps moving. These upgrades aren&rsquo;t optional forever. You put them off, they compound. The gap gets wider. The next upgrade gets worse.</p>
<p>The good news? The tooling for managing migrations — from the React team&rsquo;s codemods and incremental releases, to AI assistants that actually understand what they&rsquo;re looking at — is getting better, fast.</p>
<p>If you&rsquo;ve been sitting on the React 19 upgrade, my advice is simple: stop waiting. It&rsquo;s not going to get easier on its own. Grab the best tools you have and start pulling the thread.</p>
<p>Your future self will thank you. Or at least stop resenting you.</p>
]]></content:encoded></item></channel></rss>