Skip to content

Chapter 70: Migration: Moving an Existing React App Toward Kitsune

Most readers of this book have an existing React application.

The architecture from Parts III–VI is designed for new projects, but the readers’ practical situation is different. They have a working application, with thousands of lines of React code, with hooks and contexts and component trees and routing and state management, with a team that knows React, with stakeholders who care about the application keeps working more than about the architecture is cleaner. Suggesting they rewrite from scratch is unhelpful at best.

This chapter is about migration. How a team with an existing React (or Vue, or framework-bound) application moves toward the Kit architecture without rewriting everything at once. The pattern is borrowed, like several others in this book, from backend architecture — the strangler-fig pattern that Martin Fowler popularized in 2004. The chapter argues for moving incrementally, with explicit wedges, with the existing application continuing to ship features the whole time.

The realistic outcome of most migrations isn’t we replaced React with Kit. It’s we got significant Kit-architecture benefits without giving up the parts of React that still serve the team. That’s a win. The chapter helps readers find the version of the migration that fits their situation.

Martin Fowler’s 2004 article Strangler Fig Application names a pattern from biological observation. A strangler fig is a plant that grows around an existing tree, gradually replacing it. The original tree’s structure provides the support during the replacement; once the fig is established, the tree can be removed.

In software architecture, the pattern is the same. The legacy system continues to operate. New features are built in the new architecture. Over time, more functionality migrates. At some point — possibly never, possibly years later — the legacy system can be removed entirely. The application keeps shipping features throughout.

For frontend migration, the pattern works because the platform is shared. Both the React application and the Kit architecture render real DOM elements to the same browser. The two can coexist on the same page, or on different pages of the same application. The user doesn’t see the architectural distinction; they just see a working application.

The chapter walks through specific wedges — places to start the migration. Each wedge is a defensible architectural commitment that produces visible benefits and can be reversed if it doesn’t work out. Teams should adopt the wedges that fit their situation; not every team needs (or should attempt) every wedge.

The cheapest migration step is adding a few web components to an existing React application.

A <kit-button> rendered inside a React JSX tree works. The browser registers the custom element. The React component renders the markup. The browser instantiates the custom element. The element behaves as a real custom element — handles its own focus, keyboard, ARIA, form participation. React doesn’t know about the custom element’s internals (which is the point); React just renders the markup.

function MyReactComponent() {
return (
<form onSubmit={handleSubmit}>
<label>
Name
<kit-text-field name="name" required />
</label>
<kit-button type="submit" variant="primary">Save</kit-button>
</form>
)
}

The pattern works for most non-trivial web components. There are two known wrinkles:

React passes properties as attributes by default. For boolean attributes (disabled, loading, required), this is fine — React converts to the string "true" or omits the attribute. For non-string properties (object props, function props), React’s default attribute-based interop doesn’t work; the component receives the property as a string [object Object]. The fix is to use ref and set the property programmatically:

function MyComponent({ data }: { data: SomeObject }) {
const ref = useRef<KitDataElement>(null)
useEffect(() => {
if (ref.current) ref.current.data = data
}, [data])
return <kit-data-element ref={ref} />
}

React 19 (released in 2024) substantially improved custom-element interop, automatically distinguishing properties from attributes for known properties. For React 19+, the wrinkle is mostly gone.

React’s events don’t observe custom-element events by default. A kit-click event emitted by <kit-button> doesn’t fire React’s onKitClick (because the camel-to-kebab conversion goes one way). The application has to attach event listeners directly via ref and addEventListener:

function MyComponent() {
const ref = useRef<KitButton>(null)
useEffect(() => {
const handler = (e: CustomEvent) => console.log('clicked', e.detail)
ref.current?.addEventListener('kit-click', handler)
return () => ref.current?.removeEventListener('kit-click', handler)
}, [])
return <kit-button ref={ref}>Click</kit-button>
}

For applications that adopt several Kit components, a small wrapper library can centralize these patterns. The Lit team’s @lit/react package generates React wrappers for Lit elements with proper property and event handling. The team’s react-component-wrapper patterns are documented and widely used.

The wedge: pick three or four high-value web components (a Button, a Dialog, a few form fields), replace their React equivalents, ship the change, observe the result. The benefits — accessibility improvements, smaller component code, alignment with the design system — start showing up immediately. The risks are small (the rest of the application keeps working).

Wedge 2: The Runtime as an Enhancement Layer

Section titled “Wedge 2: The Runtime as an Enhancement Layer”

The next wedge is adding the Kit runtime to the existing application without changing how the application renders.

The runtime (Chapters 39–44) is a small piece of code that attaches to the document and listens for metadata-decorated events. Adding it to a React application is a few lines of bootstrap code:

import { createRuntime, createShell } from '@kitsune/core'
const shell = createShell({
name: 'my-app',
modules: [
analyticsModule(),
auditModule(),
notificationsModule()
]
})
shell.mount(document.getElementById('root')!)
await shell.start()
// Now render the React app normally
const root = createRoot(document.getElementById('root')!)
root.render(<App />)

The runtime attaches to the same root the React app mounts to. The two coexist. React renders its components. The runtime observes the DOM. When a React-rendered element has a data-meta-event attribute, the runtime fires the corresponding event.

The application can now declare events on existing React components:

function CheckoutButton() {
return (
<button
data-meta-event="checkout.started"
onClick={handleCheckout}
>
Checkout
</button>
)
}

The button still works the way it did before. The onClick handler runs. And the runtime observes the click, sees the metadata, fires the application-level event. Capability modules subscribed to the event receive it.

The wedge: incrementally migrate the application’s analytics, audit, and observability calls from inline React code to capability modules subscribing to runtime events. The React components stop importing the analytics SDK. The modules in the runtime own those concerns. The component tree gets cleaner; the cross-cutting concerns get centralized; the architecture’s capability layer takes shape inside the React application.

This wedge is particularly valuable for applications that have accumulated component-trap code over time. The button that imports six hooks (Chapter 35) becomes a button that declares its event. The migration happens one component at a time, gradually.

The third wedge is more ambitious: pick a section of the application and migrate it from React to Kit’s web-native components.

The selection matters. Sections that are good migration candidates:

  • Marketing pages and landing pages — typically less interactive, fewer client-side dependencies, fastest performance wins from server rendering plus the smaller Kit bundle.
  • Form-heavy admin pages — the form-association story (Chapter 49) and the metadata protocol are particularly strong here.
  • Content-heavy pages — documentation, blog, knowledge base. The platform-first approach works well; the React overhead was rarely justified.
  • Pages with strong accessibility requirements — government services, healthcare interfaces, banking, compliance contexts. The Kit architecture’s accessibility story (Chapter 29) is a real improvement over many React component libraries.

Sections that are not good migration candidates:

  • Highly interactive editors — anywhere the React reconciliation argument actually applies. Don’t fight the framework where it’s earning its keep.
  • Real-time collaborative tools — the existing infrastructure may be hard to recreate.
  • Critical user flows under deadline — migrating during a high-stakes shipping period is asking for trouble.

The pattern: identify a route or section, build the replacement in the Kit architecture, deploy it alongside the React version (different URLs, or a feature flag), validate it works, switch traffic over, deprecate the React version. The team has full control over the timing. The application keeps shipping during the migration.

The shared-design-system story matters here. If the React application uses Material UI or Chakra, the Kit-migrated section’s buttons need to look the same. This is where the Chapter 50 styling system helps — design tokens consumed by both the React components (via the React library’s theming) and the Kit components produce consistent visuals across the architectural boundary.

For applications that complete several rounds of Wedge 3, eventually most of the codebase is Kit-architected. At that point, the team can decide whether to replace React entirely.

For most teams, this isn’t worth doing. React’s component model continues to work; the team knows it; the remaining React code is paid-for. The marginal benefit of replacing the last React surfaces is small. The reasonable outcome is we have Kit for new development and the parts that benefit; we have React for the parts that work fine. Both architectures coexist permanently.

For teams that do want to complete the migration — perhaps because the React version of the framework has reached end-of-life, or because the team wants a single architectural mental model, or because supply-chain concerns favor the smaller dependency surface — the final replacement is just the previous wedges run to completion. There’s no special technique for the last 10%; it’s the same work as the first 10%.

The chapter’s position: plan for partial migration. Don’t commit to full replacement before you’ve done the early wedges. The team will know, after the first few rounds, whether the migration is producing the benefits expected.

A specific piece of infrastructure helps the migration substantially: a React adapter that provides Kit’s runtime services as React hooks.

import { useKitRuntime, useKitProvider } from '@kitsune/react'
function ProfileEditor() {
const runtime = useKitRuntime()
const user = useKitProvider(USER)
function save(data: ProfileData) {
runtime.command({
type: 'profile.save',
context: { entity: { type: 'profile', id: user?.id } },
payload: data
})
}
// ... render JSX
}

The adapter exposes the runtime to React components through hooks. React components can dispatch commands, emit events, inject providers. The runtime’s modules are the same modules; the architecture’s modules are the same modules. The React component is just one way to access the runtime, alongside the Kit components and any other framework adapters.

For the migration, the adapter means React components can participate in the architecture’s capability layer without rewriting. A React <SaveButton> can dispatch profile.save through the runtime; the runtime’s modules handle the work; the React component doesn’t import the analytics SDK or the audit module or any of the cross-cutting concerns it previously imported.

The adapter also makes the migration reversible. If a Kit-architected section needs to be modified by a React developer who isn’t yet comfortable with web components, they can drop a React component into the section and have it participate in the runtime. The two architectures cooperate; the migration doesn’t have to be all-or-nothing at any boundary.

A frequent question is how long does this take? The honest answer is it depends on the application’s size and the team’s investment.

A reasonable rough sketch:

  • Wedge 1 (a few web components inside React): a sprint. Maybe two. The team picks three or four components, builds them, ships them, learns from the result.
  • Wedge 2 (the runtime as an enhancement layer): another sprint or two. The runtime is installed; a few capability modules are written; the analytics, audit, and observability code starts migrating from inline to modules. The total amount of code change is modest.
  • Wedge 3 (sections migrated separately): variable. A simple section (a marketing page) might be a week. A form-heavy section might be a month. A complete admin area might be a quarter. The work scales with the section’s surface area.
  • Wedge 4 (full replacement): months to years, if done at all. Most teams stop short of this.

Across all four wedges, the application keeps shipping features. The migration is incremental; the team’s other work doesn’t pause. The architectural benefits accumulate as the wedges complete. By the end of Wedge 3 for a medium-sized application, the team typically has:

  • Materially smaller production bundles.
  • Centralized analytics, audit, and observability that’s easier to maintain.
  • Better accessibility for the migrated sections.
  • A capability layer that the rest of the team can build on.
  • A team that understands both React and the Kit architecture, with the ability to pick the right tool for each new feature.

That’s a successful migration, even if React is still part of the codebase.

A short pragmatic checklist for picking the first migration target:

  1. Pick something with measurable success criteria. Performance? Accessibility? Bundle size? Pick at least one metric that’s currently bad and will visibly improve.

  2. Pick something with low blast radius. The first migration shouldn’t be your highest-traffic page. Pick something where a regression won’t be catastrophic.

  3. Pick something representative. The first migration teaches the team the architecture. Pick something that exercises the patterns the team will need elsewhere (a form, a dialog, an event-rich interaction).

  4. Pick something the team can finish. A migration that drags on forever produces no visible value. Pick something that can ship in a sprint or two.

  5. Pick something with stakeholder support. The product manager, the design lead, the engineering manager all have to be aligned. The migration takes time the team would otherwise spend on features; the alignment has to be explicit.

For most teams, the answer is a form-heavy admin page that’s currently slow and has accessibility complaints. The migration produces visible benefits in performance and accessibility, the form story is one of the architecture’s strongest surfaces, the section is contained, and the stakeholders are usually willing to invest in admin-page work.

The chapter’s honest position is also that some things shouldn’t be migrated.

The team’s most-complex interactive surfaces are usually where React is earning its keep. A rich-text editor, a complex visualization, a real-time collaborative tool — these benefit from React’s reconciliation, the React ecosystem’s libraries (Slate, Tiptap, ProseMirror, react-flow, the visualization libraries), and the team’s existing React expertise. Migrating these to the Kit architecture is possible but rarely worth it.

Vendor-supplied components — payment forms from Stripe, customer-support widgets from Intercom, video players from Mux, embedded analytics dashboards — usually come as React components or framework-agnostic widgets. The team doesn’t control the code; rewriting it would be a fork. Wrap them in Kit components if needed, but don’t try to replace them.

Recently-shipped React code — anything the team has invested in heavily in the past six months — is psychologically harder to migrate than older code. The team is attached to recent work; the political cost of migration is higher; the marginal benefit is often less (the new code is usually better-structured than the old). Leave it alone unless there’s a specific reason to migrate.

The pattern: migrate the parts where the platform-first approach is clearly better, leave the parts where React is earning its keep. The migration’s success is measured by the cumulative benefits of the things you did migrate, not by the percentage of code you migrated.

The last chapter of Part VII (Chapter 71) covers testing — how the Kit architecture’s components, modules, and integration scale to a real test suite. The migration story carries forward; tests have to cover the React parts, the Kit parts, and the boundary between them.

Pick an existing React application you work on. Plan a migration to the Kit architecture.

Inventory the application: List the major pages or sections. For each one, note the complexity (low / medium / high), the interactivity (mostly static / form-heavy / highly interactive), the traffic (low / medium / high), and the current performance profile (good / fair / poor).

Identify candidates: From the inventory, pick:

  1. A Wedge 1 candidate — three or four components that would benefit from web-component replacements.
  2. A Wedge 2 candidate — a set of cross-cutting concerns (analytics, audit, observability) currently scattered through React components that could move to capability modules.
  3. A Wedge 3 candidate — a section of the application that’s a good migration target (medium complexity, form-heavy or content-heavy, decent traffic but not the highest, currently has visible problems the migration would fix).

Define success criteria: For each wedge, what would success mean? Performance metrics? Bundle size? Accessibility scores? Developer experience? Be specific.

Estimate the work: How long would each wedge take with your team’s current capacity? Be honest — most estimates are too optimistic.

Identify risks: What could go wrong? What’s the rollback plan if a wedge produces a regression?

Talk to stakeholders: Share the plan with product, design, and engineering leadership. The migration takes time that would otherwise go to features; the conversation needs to happen explicitly.

Then make a decision: do you start the migration, or not? Both are defensible. The point of the planning isn’t to commit to migration — it’s to make the choice with eyes open. Some teams will choose to migrate; some will choose to keep their existing React architecture. Both are reasonable, given the trade-offs the chapter has been describing.

The architecture this book has been arguing for is one tool among several. The team that adopts it should do so because the trade-offs fit their situation, not because the book says they should. The migration chapter’s job is to make the adoption pragmatic — small wedges, visible benefits, reversible commitments, the application keeps shipping. That’s the version of the migration that succeeds for most teams.