jQuery to React Without a Rewrite: An Incremental Frontend Modernization Path

#jQuery to React migration
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

jQuery to React migration trips up most teams before they write a single line of new code. The usual instinct is to declare the old frontend dead, stand up a fresh React project, and rebuild everything. That instinct is almost always wrong.

A full rewrite freezes feature development for months, requires your team to maintain two parallel codebases during the transition, and has a failure rate that most engineering managers quietly underestimate. The jQuery app serves real users today. The React rebuild does not exist yet.

There is a better path: migrate island by island, keep the existing app running, and introduce React component by component until the old codebase fades away rather than being torn out.

This guide walks through that path with the practical specifics that generic "modernization" advice tends to skip.


Why the Big-Bang Rewrite Fails

Before the incremental approach makes sense, it helps to understand why its alternative fails so consistently.

A jQuery application that has been in production for three or more years accumulates implicit knowledge. Event handlers that compensate for server-side quirks. DOM manipulation that mirrors edge cases in the data model. Inline $.ajax calls that have been tuned after production incidents. None of this lives in a requirements document.

When a team rewrites from scratch, they rebuild the happy path with confidence and discover the edge cases six months after go-live when the first wave of real-world users hits the new frontend. That is the moment the business realizes the rewrite cost twice what the estimate said and shipped half of what the original app could do.

The incremental path avoids this by never removing a working piece of jQuery until a working piece of React has replaced it under production conditions.


The Core Principle: React Islands

A React island is a self-contained React component tree mounted into a designated DOM node that the existing jQuery page owns. jQuery continues to control the surrounding layout and navigation. React owns only its designated container.

The pattern has two requirements:

  1. The host page provides a mount point (a <div id="react-island-xyz"></div> that jQuery does not touch).
  2. The React component receives its initial data as props or from a clean API endpoint rather than reading from the jQuery-managed DOM.

Both requirements are simple to satisfy and force a useful discipline: the React island cannot be tangled with the legacy DOM layer. That separation makes each island independently testable and independently deployable.

Mounting looks like this in its simplest form:

// Vanilla JS entry point loaded after jQuery finishes rendering the page
import { createRoot } from 'react-dom/client';
import { NotificationCenter } from './components/NotificationCenter';

const el = document.getElementById('react-notification-center');
if (el) {
  const root = createRoot(el);
  root.render(<NotificationCenter userId={window.__USER_ID__} />);
}

No framework integration required. No Next.js. No build-system overhaul on day one. Just a bundler producing a script tag that the existing backend drops into the page.


Step 1: Choose a Build Tool That Works Alongside jQuery

Your legacy app probably does not have a JavaScript build pipeline at all, or it has one from a different era. The first practical step is adding a modern bundler without disrupting the existing frontend.

Vite is the current default choice. It starts in under a second, handles JSX out of the box, and produces module-based output that loads cleanly alongside legacy scripts.

Create a frontend/ directory next to your existing public/js/ folder. Point Vite at it. Leave the legacy scripts exactly where they are. The two sets of scripts coexist on the same pages without conflict as long as you are careful not to register duplicate global event listeners.

This step takes half a day. It does not break anything. Do it before touching a single jQuery file.


Step 2: Identify the First Island

Resist the temptation to migrate the most complex widget first. Start with something that is genuinely self-contained and that your team understands completely: a notification dropdown, a date picker, a file upload control, a settings form.

The criteria for a good first island:

  • Minimal read/write coupling to the rest of the page
  • Clear API endpoint that can supply its data (or can be created within a sprint)
  • Visible enough that the team can see it working in production
  • Isolated enough that a bug does not break the page around it

A notification center is often the ideal first candidate. It reads from an API, renders its own UI, and does not affect the surrounding page state. A form that submits and triggers a full-page server reload is a worse first choice because the "success" behavior is outside the island.

If the page your team is thinking of does not have a clean API backing it, create the API endpoint first. Do not try to migrate the UI and extract the data layer simultaneously. That is two migrations at once and the source of most island failures.


Step 3: Migrate Data Fetching Before the UI

This step is invisible to users but eliminates the category of failures that come from React reading state directly off the DOM.

Audit the jQuery code for the first island. Every $.ajax call, every $(selector).text() that reads server-rendered data, every data-* attribute that the page uses to pass initial state - extract all of it.

Create a typed API module:

// api/notifications.ts
export async function fetchNotifications(userId: string) {
  const res = await fetch(`/api/v1/notifications?userId=${userId}`);
  if (!res.ok) throw new Error('Failed to load notifications');
  return res.json() as Promise<Notification[]>;
}

If the server-rendered page passes initial data via data-* attributes or inline <script> blocks, accept that as props on the React island for the first sprint. Plan to migrate to a proper API fetch in the second sprint. Mixing both is fine temporarily; what matters is that the React component never reads from the live DOM outside its own subtree.


Step 4: Expand Island by Island, Not Page by Page

The most common mistake after a successful first island is switching to a page-by-page migration strategy: "Let us convert the settings page entirely." That approach reintroduces rewrite risk at the page level.

Stay with the island pattern across multiple pages. After your notification center is working, migrate the date picker everywhere it appears. Then the file upload component. Each island migration is smaller than a page migration, ships faster, and delivers a testable increment to production.

By the time you have migrated six to eight islands, you will find that some pages are already 80 percent React. The remaining jQuery on those pages is typically navigation glue that you can replace in an afternoon.

This is also the phase where a legacy code optimization review can surface patterns worth standardizing: event-bus conventions, shared context for authentication state, error boundary placement. Getting those foundations right before the migration scales saves significant rework.


Step 5: Handle Shared State Without a Global Store

The hardest jQuery-to-React transition question is not UI rendering - it is state that multiple islands need to share. A user changes a setting in one island and a different island needs to reflect that change.

The jQuery answer was usually a global variable or a custom event dispatched on document. Neither works cleanly with React's component model.

For initial phases, lean on the server as the source of truth. When island A updates data, it fires a mutation, and island B re-fetches on a short polling interval or after a user action. This is slower than a real-time event but requires no shared state architecture.

When polling is not good enough, introduce a lightweight event bus using the browser's native CustomEvent:

// shared/eventBus.ts
export function publish(event: string, detail: unknown) {
  window.dispatchEvent(new CustomEvent(`wt:${event}`, { detail }));
}

export function subscribe(event: string, handler: (detail: unknown) => void) {
  const listener = (e: Event) => handler((e as CustomEvent).detail);
  window.addEventListener(`wt:${event}`, listener);
  return () => window.removeEventListener(`wt:${event}`, listener);
}

React components clean up their subscriptions in useEffect return callbacks. jQuery code calls publish. The two layers stay decoupled.

Only introduce a proper state management library (Zustand, Redux Toolkit) when the event-bus pattern becomes unmanageable. That threshold is higher than most teams expect.


Step 6: Retire jQuery Module by Module

Once islands cover the meaningful UI surface of a page, the remaining jQuery is usually:

  • Navigation event handlers (intercepting link clicks, handling back-button state)
  • Page initialization code that collects data from the DOM and calls APIs
  • Legacy animation or tooltip libraries that were never replaced

Navigation handlers should be the last thing you migrate, not the first. They are high-risk and often tightly coupled to server-rendered routing. Let them run until you have a proper routing layer.

Page initialization code that reads the DOM to extract server-sent data should be replaced during the API migration phase (Step 3). By the time you reach this step, most of it should already be gone.

Legacy UI libraries (jQuery UI, Select2, Chosen) can often be wrapped in a React component via useRef and the library's own imperative API. The React wrapper manages the lifecycle; the legacy library does the rendering. It is not ideal but allows you to defer replacing heavy UI components until the surrounding structure is stable.


The Migration Does Not Need to Finish

One counterintuitive finding from teams that run incremental migrations well: the migration does not need to reach 100 percent. A codebase that is 85 percent React and 15 percent jQuery on low-traffic pages has already captured most of the maintainability and velocity benefits. The cost of eliminating that last 15 percent often exceeds its value.

Set a threshold - "no new jQuery is permitted; existing jQuery only on pages below X traffic threshold" - and treat that as done. The goal was always to stop the bleeding and move the team forward, not to achieve purity.


Getting Help with the Hard Parts

The pieces described here - build tooling, island architecture, API extraction, shared state patterns - are straightforward when the codebase is well-structured. When the codebase is not (and most jQuery applications are not), each step surfaces surprises: tightly coupled DOM dependencies, missing API coverage, undocumented business logic embedded in event handlers.

If your team is about to start this migration and you want an outside read on where the risk is concentrated, that is exactly the kind of web application development and legacy code optimization work we do at Wolf-Tech.

Send a note to hello@wolf-tech.io or visit wolf-tech.io to describe your situation. Even a short conversation about the architecture before the first sprint can prevent the mistakes that add months to a migration.


FAQ

Does the incremental approach work if the jQuery app has no build pipeline at all?

Yes. Adding Vite alongside a script-tag jQuery app is a standalone step that takes a few hours. The legacy scripts stay untouched.

Should we migrate jQuery tests to React Testing Library at the same time?

No. Migrate the UI first, then retrofit the tests to the React component once it is stable. Trying to do both simultaneously slows both.

What if we are also upgrading from an old backend framework at the same time?

Separate the migrations. A frontend migration and a backend migration that happen simultaneously multiply the risk of each. Run the frontend migration first and let the backend catch up, or vice versa - but not both at once unless they are genuinely independent.

When does the incremental approach not work?

When the jQuery frontend is so deeply coupled to the server-rendered HTML structure that every component depends on DOM state set by every other component. In that case, the coupling problem has to be solved before migration and the solution may involve a limited server-side refactor first.