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:
- Foundation first — Converted core infrastructure (App.tsx, auth, theme provider, Vite config)
- Batch conversion — One massive commit converted 139 files at once
- Iterative fixes — Rather than waiting for perfection, we fixed issues as they appeared
- 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
className→class- 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.
| Metric | SolidJS | React | Improvement |
|---|---|---|---|
| First Contentful Paint | 332ms | 576ms | 42% faster |
| Memory (initial) | 34 MB | 67 MB | 49% less |
| Memory (post-interaction) | 31 MB | 43 MB | 28% less |
| Script Duration | 145ms | 1,105ms | 7.6x faster |
| DOM Elements | 995 | 1,384 | 28% fewer |
| Transfer Size | 376 KB | 683 KB | 45% smaller |
| Phone Heat | Cool | Warm | Qualitative 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:
- Side-by-side comparison — Both versions in tabs, both visible (required for accurate paint timing)
- Hard refresh testing —
Cmd+Shift+Rto bypass cache and get real metrics - Memory stress testing — Send multiple messages, track heap growth
- 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:
-
Start with Kobalte — Instead of converting Radix components, we should have planned for Kobalte from the beginning.
-
Plan for iOS scroll quirks earlier — Mobile Safari is its own beast. We should have tested on real iOS devices from day one.
-
More comprehensive a11y testing upfront — We found 7 elements without labels (5 buttons, 2 inputs) in our accessibility audit. Should have caught those earlier.
-
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