Skip to content

Moving the component library while the team kept shipping

115 of 120screens in the main app import the library

I extracted and reshaped the team's React component library out of the main app and into its own package, in one 1,741-file change that landed while about ten feature PRs merged around it. Three apps build on it now.

  • Isherwoods
  • 2025
  • Private

115 of 120screens in the main app import the library

The component library, before and after it left the main appTwo scenes in one drawing. On the left, before: the main app as a tray, with the library as a lower floor on it. In the library, three parts sit a little off the grid, with loose lines tangled between them: the library's files reached across its own areas. Behind the library, on the app's floor, is a block of app code, tied to it both ways: one line runs from the app code into the library, which imported app code 65 times, and one runs back, since the app built nearly all its screens on the library. Finding every one of those crossings was step 1. One dashed line runs from the library to the features layer on the right: the single change that moved it, 1,741 files (step 3). On the right, after: the UI package, a base and five layers, top to bottom: screens; features; components and hooks; core; utils, types and config. The features layer holds the same three parts in a row: details, list and search. List may import search; an import from list to details stops at a cross, because lint forbids one feature importing another (step 4). The Fluent 2 theme came across into the package and sits on its base (step 5). From the package's one public entry, one line runs off the base and forks to three apps standing in a row in front: the main app, the replication app and the schema editor. Chat now sits on the main app, behind a hook (step 2).DetailsListSearchApp codeScreensFeaturesComponents and hooksCoreUtils, types and configFluent 2 themeOne public entryMain appChat behind a hookReplication appSchema editor appBeforeThe library, inside the main appAfterUI package12345Imports of app code65Imports of app code65
The same parts before and after: a tangled folder inside the main app, then a package in five layers. Redrawn from employer work, with generic names. Each number is a decision below. Press one to read it.Scroll sideways to see all of it
The component library, before and after it left the main appTwo scenes in one drawing. On the left, before: the main app as a tray, with the library as a lower floor on it. In the library, three parts sit a little off the grid, with loose lines tangled between them: the library's files reached across its own areas. Behind the library, on the app's floor, is a block of app code, tied to it both ways: one line runs from the app code into the library, which imported app code 65 times, and one runs back, since the app built nearly all its screens on the library. Finding every one of those crossings was step 1. One dashed line runs from the library to the features layer on the right: the single change that moved it, 1,741 files (step 3). On the right, after: the UI package, a base and five layers, top to bottom: screens; features; components and hooks; core; utils, types and config. The features layer holds the same three parts in a row: details, list and search. List may import search; an import from list to details stops at a cross, because lint forbids one feature importing another (step 4). The Fluent 2 theme came across into the package and sits on its base (step 5). From the package's one public entry, one line runs off the base and forks to three apps standing in a row in front: the main app, the replication app and the schema editor. Chat now sits on the main app, behind a hook (step 2).DetailsListSearchApp codeScreensFeaturesComponents and hooksCoreUtils, types and configFluent 2 themeOne public entryMain appChat behind a hookReplication appSchema editor appBeforeThe library, inside the main appAfterUI package12345Imports of app code65Imports of app code65
The same parts before and after: a tangled folder inside the main app, then a package in five layers. Redrawn from employer work, with generic names. Numbers match the decisions below.Scroll sideways to see all of it

Diagram. The same parts before and after: a tangled folder inside the main app, then a package in five layers. Redrawn from employer work, with generic names. Numbers match the decisions below.

The same parts before and after: a tangled folder inside the main app, then a package in five layers. Redrawn from employer work, with generic names. Numbers match the decisions below.

This is employer work, so it's described rather than shown. Names are generic and the diagram is redrawn. There's no code or UI from the product.

01Problem

The team's React component library was good, but it had grown into the main app it was built in. It imported app code 65 times, 17 of them into the app's API layer. It reached into the app's modal registry, an image-⁠library hook and an admin screen's toolbar types. The generic details screen, which every record type uses, had the app's chat wired straight into it. Inside, 59 files imported across the library's own areas. Nothing else could use it without pulling in the main app.

The team didn't stop for this. Feature work kept landing on the same code while my branch was open. The product was also partway through its move to Microsoft's Fluent 2, and the theme lived in the app.

02Decisions

  1. Find every place the library reached into the app

    I could have moved the files and fixed whatever broke. In a codebase this size, that means finding the breakages one build at a time. So I listed every crossing first: the library's imports into the app, and between its own areas. That turned the job into a list I could work down, and I'd know when I was done.

  2. Cut each link so the library doesn't know about the app

    Each import got the same call. If it was library code, it moved into the library. If not, it went back out to the app. Chat is the clearest case. The details screen is generic, so it shouldn't know chat exists. I moved chat into the main app, behind a hook and an app-⁠level details screen. The library's screen stays generic, and the app that wants chat adds it.

  3. Land it as one change on a long-⁠running branch

    The other option was a run of smaller PRs, each leaving the app working. I went with one PR of 1,741 files, about 119k lines added and 96k removed. The app never sat half-⁠moved, with components in two homes. The cost was keeping the branch alive while about ten feature PRs landed on main, and bringing each of those changes across. It needed two or three fixes after it merged.

  4. Fence the boundaries with lint, not review alone

    I could have relied on code review, or written custom lint rules. I configured standard ESLint rules instead, so there's nothing of ours to maintain. They stop one feature importing another, with one exception: the list may use search. A few path and naming rules sit alongside. The package is built in layers, from screens down to utils, types and config, and imports should only go down. That part is a convention, not a check, and three small exceptions have crept in.

  5. Bring the Fluent 2 theme across with the library

    The product was already partly on Fluent UI, and its Fluent 2 theme lived in the app. It came across with the library, so every app gets the same look from the package, not from a copy. My part of the Fluent 2 work began in March 2025. It reaches nearly every screen now, and the old Fluent styles left the main app in May 2026.

03Result

The main app moved onto the package in July 2025, the replication app in November 2025 and the schema editor in March 2026. 115 of the main app's 120 screens import it. It has one public entry point and five layers.

I built the package with Rollup first, and it was slow. Adding an esbuild plugin to Rollup took the JavaScript build from about 30 seconds to 2 or 3. The type files are what slow it down now, which moving to TypeScript 7 could fix in time.

04How I worked

Four developers built the library inside the main app from January 2023, and colleagues did the early Fluent UI work from 2022. During the extraction, two senior colleagues were working in the same repo. The extraction and the decisions above were mine. From July to September 2025 I made 11 of the library's 16 follow-⁠up commits.

  • React
  • TypeScript
  • Fluent UI
  • Rollup
  • esbuild
  • ESLint
  • Lerna
  • Nx

05What I'd do differently

I'd land it in smaller PRs, each moving one area and leaving the app working. One diff that size was hard to review well, and hard to back out. I'd also build it with tsup instead of Rollup: it's fast by default, handles TypeScript well and needs less configuration. And I'd enforce the layers with stronger lint rules from day one, so a wrong import fails the build instead of relying on convention.