A plain JavaScript app has no framework to translate. No models, no components, no router, just a page and a script that changes it. So the move to React looks simple, until you notice the state is tucked away in text content, CSS classes, inline styles and input boxes, with the bugs sitting right next to it. Here's how we found that state, mounted React into an existing page, shared values between old and new code, turned DOM updates and createElement loops into components, and tested a page that never had tests.
Every other migration we've written about started with a framework. Angular had modules and services, Ember had its conventions, Backbone had models and views. This time there was nothing to compare. Just an HTML page, three CSS files and a script that grabs elements and changes them when you click something.
We assumed this would be the easy one, and mostly it was. But it took us a while to see where the work actually sat. In a vanilla app, the page is the state. Is the forecast open? Check whether a <section> has a class. Which city is on screen? Read the text in an <h2>. Is there an error? Look at an inline style.display. React wants all of that written down as data, so before you can move anything, you have to find it.
Source code: everything in this post comes from a small weather app built with plain HTML, BEM CSS and ES module JavaScript, talking to the OpenWeatherMap API. You search for a city, see the current weather, open a 5-day forecast and flip between light and dark themes. We rebuilt it in React 18 and kept the same markup, class names, stylesheets and API calls. Both versions are at cobuild-tech/migratex-examples. The app is small enough that we just rewrote it. Bigger apps we move piece by piece, and that's mostly what this post is about.
Before You Start
Why leave, and when not to
Nothing's wrong with plain JavaScript for a small page. It loads fast, there's nothing to upgrade, and anyone on the team can read it. The trouble starts when the page stops being small. Every feature adds another handler that pokes at the same elements, and one day nobody can say which handler left the page looking the way it does. Our weather app is only about 230 lines, and it already had a handful of bugs like that. We'll get to them.
So it's worth asking whether you need React at all. If the page is small, stable and rarely touched, leave it alone, or add a bit of structure without a framework. The point to move is when every change starts with someone tracing which handlers touch which elements, and getting it wrong now and then.
Find the state hiding in the page
Before anyone puts a number on it, make a list of everywhere the app keeps state. In a framework app most of it lives in models or stores. In a vanilla app it's scattered, and a lot of it is sitting in the DOM. Our little app managed to use every one of these:
| Where the state lives | What it looks like | In our example |
|---|---|---|
| Text content | The page shows a value, and later code reads it back | City name, temperature and description set with textContent
|
| CSS classes | A class decides whether something is visible or active |
.is-hidden on the result, the forecast and the Show button |
| Inline styles |
style.display switches a block on and off |
The error box, shown with style.display = 'block'
|
| Form values | Handlers read inputs at the moment they run | The forecast button reads the search box, not the searched city |
| Attributes | State stored on <html> or data-* attributes |
data-theme on <html>
|
| Browser storage | Saved between visits | The chosen theme in localStorage
|
| Built markup | Elements created in a loop and appended | Forecast cards built with createElement
|
For a rough size, search the code for querySelector, getElementById, classList, style., textContent, innerHTML and addEventListener, and count what comes back. Most hits are the script writing state to the page. Look harder at the ones where it reads the page back, as if the DOM were a variable. In our experience, that's where the bugs live.
Get onto a bundler first
If the page still loads a stack of <script> tags that share globals, sort that out before any React shows up. Move to ES modules and a bundler. Vite is happy to serve a plain HTML page, so the old app keeps running untouched while you set things up. Ours already used type="module" and import, so this step cost us almost nothing.
It's also a good moment to pull configuration out of the code. The original had its API key pasted right into api.js. Ours reads it from a Vite environment variable and puts a plain message on the page if it's missing. One caveat: anything in a frontend bundle is public. If a key really has to stay secret, it belongs behind a small server-side proxy.
Running Vanilla JS and React Together
It's tempting to rewrite the whole page in one go. For a page people use every day, we wouldn't. The safer route is Martin Fowler's strangler fig: new code grows around the old until the old code has nothing left to do. A vanilla page is about the friendliest place to try this, since there's no framework that thinks it owns the page.
Mounting React into an existing element
Pick one self-contained part of the page, give it an empty container, and hand that to React with createRoot. The old script keeps doing everything else:
<!-- index.html: the forecast is now React's, everything else is unchanged -->
<section class="weather__section js-weather-result">…</section>
<div id="forecast-root"></div>
// main.js
import { createRoot } from 'react-dom/client';
import { Forecast } from './react/Forecast';
createRoot(document.getElementById('forecast-root')).render(<Forecast />);
There's one rule you can't bend. Once React owns a container, the old script keeps its hands off everything inside it. Break that and React and the script will disagree about what's on the page, and you'll lose an afternoon working out which one is lying. Move whole areas at once, and delete the old code for an area in the same commit that hands it over.
Sharing state with the rest of the page
For a while, old and new code will need some of the same values, like the city someone just searched for. Don't copy them back and forth. Put them in one tiny store both sides can use. React's useSyncExternalStore only needs a way to read the value and a way to hear when it changes:
// store.js: plain JavaScript, usable from both sides
let state = { city: null };
const listeners = new Set();
export const store = {
get: () => state,
set(next) {
state = { ...state, ...next };
listeners.forEach((listener) => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
// In React
const { city } = useSyncExternalStore(store.subscribe, store.get);
The old search handler calls store.set({ city }), and the React forecast picks it up by itself. Notice that set always builds a new object. React compares by reference, so that's how it spots the change. For the odd signal going the other way, from React back to the old code, a CustomEvent on document does the job without tying the two together.
From DOM Updates to State
This is where most of the effort went. Here's the original search handler, trimmed a bit. It reads the input, then reaches into the page and changes six different things:
searchButton.addEventListener('click', async () => {
const city = inputElement.value.trim();
if (!city) return;
try {
const data = await getWeatherByCity(city);
cityNameElement.textContent = `${data.name}, ${data.sys.country}`;
temperatureElement.textContent = `${formatted}°C`;
iconElement.src = `https://openweathermap.org/img/wn/${icon}@2x.png`;
resultSection.classList.remove('is-hidden');
} catch (err) {
resultSection.classList.add('is-hidden');
showErrorUI();
}
});
In React, the handler only records which city was searched. Fetching, loading and errors all follow from that, and the markup just describes each case:
function onSearch(city) {
setForecastOpen(false);
setSearch((previous) => ({ id: (previous?.id ?? 0) + 1, city }));
}
const weather = useCurrentWeather(search);
{weather.error && <ErrorBanner error={weather.error} />}
{weather.isSuccess && <CurrentWeather weather={weather.data} />}
Writing the state down is what flushed out the first real bug. The original forecast button read the search box again when you clicked it. We found it by accident: search London, type "Oslo" without pressing Search, click Show, and you get London's weather with Oslo's forecast underneath. Nobody would design that. It's just very easy to do when the page is the state. With a single search value, the whole page agrees on the city, and that bug has nowhere to come from.
A couple of smaller things came along. The id means searching the same city twice sends a fresh request, like the original did. And the old Enter handler called searchButton.click(); that turned into a plain onKeyDown on the input calling the same function. We thought about wrapping it all in a <form>, but left the markup alone so the CSS and test selectors didn't have to change.
Loops that build markup become components
Vanilla code loves building lists with createElement and appendChild, and those functions usually do two jobs at once: work out the data, then draw it. Ours, displayForecast, grouped 40 three-hourly entries into days and built a card for each in the same loop. Split those apart. The data half becomes a pure function you can test without a browser:
export function groupDailyForecast(list, now = new Date()) {
// One entry per local day, skipping today, with min, max and a midday icon.
}
export function Forecast({ data }) {
const days = useMemo(() => groupDailyForecast(data.list), [data]);
return days.map((day) => <ForecastCard key={day.day} forecast={day} />);
}
Testing that function found bug number two. The original picked each day's icon from an entry between 10:00 and 14:00. The last day in the forecast is usually only partly covered, so often there's no entry in that window, the icon URL comes out as wn/@2x.png, and you get a broken image. We spotted it on the live original during our side-by-side check. The React version falls back to the entry closest to noon, and a unit test keeps it there.
What stays outside React
Not everything wants to be a component. A few things work at the level of the whole page, and we left them there:
-
The theme. The original set
data-themeon<html>from a deferred module, so dark-mode users got a flash of light on every load. We kept the attribute, added a two-line script in<head>that applies the saved theme before the first paint, and moved the toggle into a smalluseThemehook. -
The stylesheets. All three CSS files came across untouched. The one addition was
#root { display: contents }. React's root element sits between<body>and the page, and the original layout centred<body>'s direct children, so the theme button suddenly hugged the left edge.display: contentsmakes the wrapper behave as if it weren't there. - Third-party scripts. Analytics, chat widgets and the like can usually stay put, as long as they don't rewrite markup React now owns.
Testing the Migration
Like most vanilla apps, ours had no tests. So before touching anything, we wrote down what it actually does, weird parts and all. Michael Feathers calls these characterisation tests in Working Effectively with Legacy Code: they capture what the code does today, not what anyone thinks it should do. Reading 230 lines slowly turned up five things nobody would have put in a spec:
- The forecast is for whatever's in the search box when you click Show, not the city on screen.
- Search for a new city and the old city's forecast stays open underneath.
- If the forecast request fails, the page shows nothing. There's just a message in the console.
- "Loading..." gets written into a section that was hidden a line earlier, so nobody ever sees it.
- The last forecast day often has a broken icon.
Each one became a test, either pinning the old behaviour or recording a decision to change it. We fixed all five and listed every change in the project README, along with the things we kept on purpose, like rounding the current temperature down to one decimal while the forecast rounds to whole degrees. Odd, but it's what users were used to.
On a bigger app, we'd write the main user journeys as Playwright tests and get them green against the vanilla page first, then point the same tests at React. Keep the original class names and ids in the React markup and both versions share the same test handles. Mock the API with MSW and one fake server answers for both. Inside the React suite, wait for what a user would see using Testing Library's findBy queries. Our 24 tests cover search, the Enter key, errors, loading, the forecast grouping and the saved theme. We froze the clock so the forecast days never shift under the tests.
Finishing the Migration
Helping the team settle in
People who write good plain JavaScript usually know the DOM better than anyone else on the team. That doesn't go to waste. It just has to point somewhere new. These are the habits we see most in first React pull requests:
-
Calling
querySelectorinside a component. If something should look different, change state and let the markup follow. Save refs for focus, scrolling and measuring. -
Wiring up
addEventListenerby hand. PutonClickand friends on the element. Listeners onwindowordocumentgo in an effect that removes them again. -
Building HTML strings. JSX escapes values for you.
dangerouslySetInnerHTMLshould be rare, and someone should always look twice at it. - Using effects to keep two values in sync. Usually the second value can just be worked out during render. The React docs on effects explain this well.
One thing that helps more than any tutorial: a short internal page with one of your own handlers next to its React version. And for the first few weeks, review React pull requests in pairs, one person who knows the old page and one who knows React. Both of them learn something.
The removal checklist
You're done when the old script tags are gone and React draws the whole page. Before that last commit, we go through this list:
- No code outside React calls
querySelector,classListorinnerHTMLon elements React renders. - Every piece of state from your inventory has a home in React state, a query, the URL or storage.
- Global variables and
window.*helpers are gone or imported as modules. - Configuration like API keys comes from the environment, not the source.
- Anything that ran before the page loaded, like the theme, still runs before the first paint.
- The shared store and custom events used during the move have been removed.
Keep the last vanilla build deployable behind a flag for a little while, just in case. Once real traffic has stayed quiet for long enough, delete it.
Looking back, there was less to translate here than in any framework migration we've done, and more to dig up. No framework to map, but plenty of state tucked into text, classes, styles and input boxes, with bugs right next to it. If we had to boil it down: write down what the page is holding before you estimate, get onto a bundler, hand React one container at a time, and know what the page really does before you test both versions against it.
Try It Yourself: the Vanilla JS to React Plugin
We've packaged the approach in this guide as a free, open-source plugin for Claude Code. vanillajs-to-react works on vanilla JavaScript apps and pages, from script-tag pages to ES-module apps, and it handles both full rewrites and step-by-step strangler-fig migrations. The source is on GitHub at cobuild-tech/cbx-plugins.
| Part | Name | What it does |
|---|---|---|
| Skill | cobuildx-ai-vanillajs-to-react-migration |
The migration workflow: assess, choose a strategy, plan, then migrate one unit per run |
| Command | /vanillajs-to-react:plan |
Inventories the app and writes a migration plan to .migration/ for you to approve, without changing any app code |
| Agent | vanillajs-inventory |
A read-only sweep of the Vanilla JS codebase: state hidden in the DOM, event listeners, markup-building code, globals, storage and timers |
| Agent | vanillajs-parity-reviewer |
Compares each migrated piece with its Vanilla JS original and lists every behaviour difference |
| MCP server | context7 |
Looks up current React, React Router and TanStack Query docs before writing code |
To install it in Claude Code, add our marketplace and install the plugin:
/plugin marketplace add cobuild-tech/cbx-plugins
/plugin install vanillajs-to-react@cbx-plugins
The same plugin also works in Cursor, in VS Code with GitHub Copilot, and in the GitHub Copilot CLI (copilot plugin install vanillajs-to-react@cbx-plugins). The plugin README has the setup for each one.
Once it's installed, open your Vanilla JS project and run /vanillajs-to-react:plan, or simply ask Claude something like "Migrate this vanilla JS app to React" or "Turn this createElement loop into a React component". The plugin keeps its progress in a .migration/ folder in your repo (state.json, assessment.md, decisions.md, plan.md and a note for each unit), so commit it and the migration can carry on across sessions and teammates. Each run migrates and validates one unit, then stops. Say "continue the migration" for the next one, or ask "what's left to migrate?" for a status update.
Your code stays with you. The plugin is instructions only. Its only output besides your migrated code is the
.migration/folder. The bundled Context7 server receives library names and documentation questions, never your source code. Found a problem or want a feature? Open a GitHub issue.
About MigrateX
MigrateX is our software and services platform for moving software, data and infrastructure from one technology to another. It brings automation and experienced engineers together, so the repetitive work gets done quickly and the tricky decisions get the human attention they need.
This guide is how we actually run vanilla JavaScript to React projects with MigrateX. In practice it looks like this:
- Assessment. We scan your pages and map where the state really lives: text, classes, inline styles, inputs, globals and storage, along with every handler that reads it back. You get a clear picture of where the effort sits before anyone commits to a timeline.
- Migration plan. Together with your team, we decide which parts of each page move first, what the shared state looks like while both versions run, and what can simply be deleted.
- Step-by-step migration. Automation handles the repetitive parts, like moving to modules and a bundler, turning markup into JSX and pulling data logic into tested functions. Our engineers handle the parts that need judgement, like state, data fetching and the bridges between old and new code.
- Proof that it works. We write down how your pages behave today, oddities included, and run the same Playwright tests against both versions. A page only counts as migrated when it passes on both.
- Handover and clean-up. We remove the bridge code and the old scripts, keep a way back until traffic is quiet, and leave your team with a React codebase they know and own.
What you get along the way:
- No big-bang release. Your site keeps running and shipping features while the migration happens in the background.
- Less risk. Shared tests and a way back mean users don't notice the move, and you can pause at any point.
- A team that's ready. We pair with your developers throughout, so they're comfortable with React long before the last old script is gone.
- More than front ends. The same approach covers frameworks like Angular, Ember and Backbone, as well as data and infrastructure migrations.
The code behind this guide is at github.com/cobuild-tech/migratex-examples. Got a page that started as a few handlers and turned into something nobody wants to touch? We'd like to hear about it. Tell us a bit about it through Contact Us, and we'll start with a free conversation about where the effort is likely to be.
Source code
- CobuildX AI. vanillajs-to-react: a Claude Code plugin for Vanilla JS to React migrations. https://github.com/cobuild-tech/cbx-plugins/tree/main/vanillajs-to-react
- CobuildX AI. migratex-examples: a weather app built with plain HTML, BEM CSS and ES module JavaScript, and its React 18 rewrite, with a README describing the migration. https://github.com/cobuild-tech/migratex-examples/tree/main/vanillajs-react
Migration strategy
- M. Fowler. StranglerFigApplication. martinfowler.com, 29 June 2004. https://martinfowler.com/bliki/StranglerFigApplication.html
- M. Feathers. Working Effectively with Legacy Code. Prentice Hall, 2004.
The web platform
- MDN. CustomEvent. https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent
- MDN. display: contents. https://developer.mozilla.org/en-US/docs/Web/CSS/display#contents
- OpenWeatherMap. Weather API. https://openweathermap.org/api
- Vite. Env Variables and Modes. https://vitejs.dev/guide/env-and-mode
React and testing
- React. createRoot. https://react.dev/reference/react-dom/client/createRoot
- React. useSyncExternalStore. https://react.dev/reference/react/useSyncExternalStore
- React. Manipulating the DOM with Refs. https://react.dev/learn/manipulating-the-dom-with-refs
- React. You Might Not Need an Effect. https://react.dev/learn/you-might-not-need-an-effect
- Playwright. https://playwright.dev/
- Testing Library. Async methods (
findByqueries). https://testing-library.com/docs/dom-testing-library/api-async/
Libraries
- TanStack Query. https://tanstack.com/query/latest
- MSW (Mock Service Worker). https://mswjs.io/
- Vite. https://vitejs.dev/
Originally published on the CobuildX blog.






Top comments (0)