DEV Community

Cover image for The State Is in the DOM: Taking a Vanilla JavaScript App to React
Emmanuel R for CobuildX AI

Posted on Originally published at cobuildx.ai

The State Is in the DOM: Taking a Vanilla JavaScript App to React

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

Before you start: in a vanilla JavaScript app, state hides in the page itself, in text content, classes, inline styles, input values, module variables and localStorage. Find each piece and give it a name before estimating; a quick search for querySelector, classList, style., textContent, innerHTML and addEventListener sizes the job

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

Running vanilla JS and React together: React mounts into an existing element with createRoot, both sides read and write one small store through useSyncExternalStore, and the remaining DOM code talks to React through custom events, until React owns the page and the old script is removed

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 />);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

From DOM updates to state: the vanilla handler finds elements, sets textContent and src, and toggles is-hidden and style.display, while other handlers read the input box back; in React the handler only updates state, the markup is drawn from that state, and the city is committed once on search so every part of the page agrees

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();
  }
});
Enter fullscreen mode Exit fullscreen mode

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} />}
Enter fullscreen mode Exit fullscreen mode

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} />);
}
Enter fullscreen mode Exit fullscreen mode

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-theme on <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 small useTheme hook.
  • 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: contents makes 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.

Characterisation tests record what the vanilla app does today, including the odd parts; the same Playwright journeys and MSW mocks then run against the vanilla page and the React app, and a journey counts as migrated only when it passes on both

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 querySelector inside a component. If something should look different, change state and let the markup follow. Save refs for focus, scrolling and measuring.
  • Wiring up addEventListener by hand. Put onClick and friends on the element. Listeners on window or document go in an effect that removes them again.
  • Building HTML strings. JSX escapes values for you. dangerouslySetInnerHTML should 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, classList or innerHTML on 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.

The removal checklist: no DOM code touching React's elements, every piece of state has a home, globals gone, configuration from the environment, pre-paint work still runs first, bridge code removed; then the old script tags are deleted, with the last vanilla build kept behind a flag until traffic stays quiet

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
Enter fullscreen mode Exit fullscreen mode

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

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Migration strategy

The web platform

React and testing

Libraries


Originally published on the CobuildX blog.

Top comments (0)