From React to SolidJS: A Migration Story

How we migrated 100+ components from React to SolidJS, achieved 7.6x faster script execution, and automated the entire testing process with Claude Code.

From React to SolidJS: A Migration Story

We just finished migrating Ditto’s entire frontend from React to SolidJS. 139 files. 100+ components. Complete rewrite.

The results exceeded our expectations. Here’s the full story—what worked, what broke, and what we learned along the way.

Why We Migrated

This wasn’t a reactive decision. React was working fine. But “fine” isn’t good enough when you’re building an app people use every day on their phones.

We’d been watching SolidJS for a while. Its fine-grained reactivity model promised better performance without the overhead of a virtual DOM. The benchmarks looked impressive, but benchmarks aren’t production.

We decided to find out for ourselves.

The Migration Approach

Here’s where it gets interesting. We used Claude Code (Anthropic’s AI coding assistant) with the Max plan to accelerate the entire migration. What would have taken weeks took days.

Our approach:

  1. Foundation first — Converted core infrastructure (App.tsx, auth, theme provider, Vite config)
  2. Batch conversion — One massive commit converted 139 files at once
  3. Iterative fixes — Rather than waiting for perfection, we fixed issues as they appeared
  4. Automated testing — Used Chrome browser automation for side-by-side performance testing

The batch conversion was bold, maybe even reckless. But with Claude Code handling the mechanical transformation work, we could focus on the interesting problems.

SolidJS Gotchas We Learned

SolidJS looks like React. It is not React. Here’s what tripped us up:

1. No Prop Destructuring

// This breaks reactivity in SolidJS
function Component({ name, count }) {
  return (
    <div>
      {name}: {count}
    </div>
  );
}

// This works
function Component(props) {
  return (
    <div>
      {props.name}: {props.count}
    </div>
  );
}

In React, components re-render when props change. In SolidJS, the component function runs once and the JSX updates reactively. Destructure your props and you break the reactive connection.

2. No Early Returns

// This doesn't work in SolidJS
function Component(props) {
  if (props.loading) return <Loading />;
  return <Content data={props.data} />;
}

// Use Show instead
function Component(props) {
  return (
    <Show when={!props.loading} fallback={<Loading />}>
      <Content data={props.data} />
    </Show>
  );
}

SolidJS builds a reactive graph, not a function that re-runs. Early returns break that model. The <Show> component handles conditional rendering correctly.

3. Different Suspense Behavior

React Suspense and SolidJS Suspense look similar but behave differently. In SolidJS, you can’t return early with JSX because you’re literally constructing a render graph. We had to rethink several data-fetching patterns.

4. State Management Evolution

Our state management evolved through the migration:

  • Stage 1: Window events (to avoid HMR issues with module-level signals)
  • Stage 2: Signals (simpler cases)
  • Stage 3: Stores with createStore (complex state)

The final architecture uses Solid Stores for most shared state, which feels cleaner than our original React Context approach.

Bugs We Fixed

Real talk: we broke a lot of things. Here are the highlights:

iOS Safari Scrolling

This was the worst. We had nested scrollable containers, and iOS Safari was confused about which one should scroll. The -webkit-overflow-scrolling: touch property wasn’t being applied consistently.

Fix: Set the outer container to overflow-y: visible and only let the inner container scroll. Added touch-action: pan-y for good measure.

shadcn/ui → Kobalte Migration

React has shadcn/ui (built on Radix UI). SolidJS has Kobalte. Same headless component philosophy, different APIs.

The migration involved:

  • Different data attributes (data-[state=*]data-[expanded]/data-[selected])
  • Different slot patterns
  • classNameclass
  • Adapting our existing shadcn component patterns to Kobalte equivalents

In hindsight: start with Kobalte from day one if you know you’re using SolidJS.

Layout Shifts from Audio Controls

Our audio controls were expanding the header and causing layout shifts. Portal-based overlays to the rescue—we moved them to a fixed-position overlay (think Spotify mini player).

Import Syntax Errors

Lots of small things: duplicate imports, stray commas, SolidMarkdown not being a default export, JSX.Element import issues. Death by a thousand paper cuts.

Performance Results

Now for the good stuff. We ran extensive side-by-side testing using Chrome automation tools. Both apps visible, hard refresh, identical interactions.

MetricSolidJSReactImprovement
First Contentful Paint332ms576ms42% faster
Memory (initial)34 MB67 MB49% less
Memory (post-interaction)31 MB43 MB28% less
Script Duration145ms1,105ms7.6x faster
DOM Elements9951,38428% fewer
Transfer Size376 KB683 KB45% smaller
Phone HeatCoolWarmQualitative win

The 7.6x faster script duration is the standout. That’s not a typo—SolidJS’s compilation model means less JavaScript executing at runtime.

We also ran memory stress tests (3+ consecutive messages) and both apps stayed stable. No memory leaks on either side.

The Claude Code + Chrome Testing Loop

This deserves its own section because it was crucial.

We used Claude Code with the Chrome MCP tools to automate our testing:

  1. Side-by-side comparison — Both versions in tabs, both visible (required for accurate paint timing)
  2. Hard refresh testingCmd+Shift+R to bypass cache and get real metrics
  3. Memory stress testing — Send multiple messages, track heap growth
  4. Accessibility audits — Automated checks for missing labels, ARIA attributes

One unexpected challenge: paint timing APIs (performance.getEntriesByType('paint')) only record when the tab is visible. We had to keep both tabs in view during testing, which required some coordination.

The automation let us test obsessively. Every change, instant feedback. That tight loop is what made the rapid iteration possible.

What We’d Do Differently

Hindsight is 20/20:

  1. Start with Kobalte — Instead of converting Radix components, we should have planned for Kobalte from the beginning.

  2. Plan for iOS scroll quirks earlier — Mobile Safari is its own beast. We should have tested on real iOS devices from day one.

  3. More comprehensive a11y testing upfront — We found 7 elements without labels (5 buttons, 2 inputs) in our accessibility audit. Should have caught those earlier.

  4. Document the gotchas as we found them — We’re writing this blog post partly so we don’t forget what we learned. (We later wrote a detailed post on SolidJS anti-patterns covering everything we fixed post-migration.)

Conclusion

SolidJS delivers on its promises. The performance gains are real and substantial.

But the real enabler was Claude Code. The mechanical work of converting 139 files, fixing import issues, adapting to new APIs—that’s exactly the kind of thing AI assistance excels at. We could focus on the hard problems (iOS scrolling, state management patterns) while Claude handled the tedious transformations.

Would we do it again? Absolutely. In fact, we’d do it faster next time.

If you’re considering a similar migration, here’s my advice: go for it, but respect the differences. SolidJS isn’t React with a different name. It’s a fundamentally different mental model. Once you internalize that, everything clicks.


Questions about our migration? I’m happy to chat. Reach out anytime.

We’ve packaged our SolidJS patterns into a reusable skill for AI coding agents:

npx skills add https://github.com/omniaura/skills --skill solidjs-patterns

— Peyton