jQuery isn't a framework, so there's no big structure to convert. Instead, a jQuery app has lots of event handlers, and each one has to keep the page up to date by hand. When one forgets, you get a bug. Here's how we listed what jQuery was doing, ran it alongside React, wrapped the plugins we couldn't replace yet, swapped hand-updated variables for React state, and tested a page that had never been tested.
Most web developers have written jQuery at some point. A lot of the web still runs on it. And many teams still have one old page built on $(document).ready that nobody wants to touch.
jQuery isn't a framework. There are no modules, components or routes to convert, so we expected this to be the easiest migration so far. In a way it was. But one thing kept coming up. In a jQuery app, nothing updates the page by itself. When you click a button, the click handler has to update every number and every class that depends on it. If it forgets one, you get a bug.
React works the other way round. You store what the user typed, and React works out everything else from that each time it draws the page. There's nothing to forget. Most of this post is about making that switch.
Source code: the examples come from a small tip calculator built with HTML, CSS and jQuery 3.6. You type in a bill, pick a tip or enter your own, type in the number of people, and it shows the tip and total for each person. We rebuilt it in React 18 with the same HTML, class names, text and CSS. Both versions are at cobuild-tech/migratex-examples. The app is small, so we simply rewrote it. With a bigger app we'd move it over in pieces, and that's what most of this post covers.
Before You Start
Should you move at all?
There's nothing wrong with jQuery. It's still maintained, it's small, and if your page only needs a couple of click handlers and a popup, keep it. Problems start when the page grows into an app. Every new input needs new handlers, and every handler needs to know about everything it affects.
Our calculator is only about 70 lines of jQuery, and it still had more bugs than features. (We'll get to them.) So here's a simple test. Do you have to read every handler before you change anything? Do things still break anyway? Then it's time to move.
Make a list of what jQuery does for you
People say "we use jQuery", but jQuery does lots of different jobs, and some are much harder to replace than others. Before you guess how long it'll take, put every use into one of these groups:
| What jQuery does | What it looks like | What it becomes in React |
|---|---|---|
| Changes the page |
$('#total').html(x), .addClass(), .prop('disabled', …)
|
JSX built from state |
| Handles events |
$('.share').click(…), .on('input', …)
|
onClick and onChange on the element |
| Handles events for many elements at once | $(document).on('click', '.row', …) |
A handler on each row, or one on the list |
| Talks to the server |
$.ajax, $.getJSON, .done() / .fail()
|
fetch, plus a data library like TanStack Query |
| Animates things |
.fadeIn(), .slideToggle(), .animate()
|
A CSS transition on a class |
| Stores values on elements |
.data('id'), data-* read back later |
Props and state |
| Plugins | Date pickers, sliders, Select2, DataTables | A React library, or the plugin wrapped for now |
Most of the groups are simple swaps. Plugins are the exception. Each plugin is like a small app of its own, with its own HTML, events and state. You either find a React version or wrap the plugin for a while. Count plugins separately, and check whether each one has a good React replacement before you give anyone a date.
Next, look for variables that several handlers share. Ours had three at the top of the file: total, people and tipPerc. Four different handlers changed them. Each of those changes is a chance for the numbers and the screen to stop matching, and that's exactly where we found our bugs.
Bundle jQuery first
Most jQuery pages load it with a <script> tag from a CDN and use the global $. Before you add React, switch to a bundler like Vite and write import $ from 'jquery' in each file that uses it. Nothing breaks, and you can now see exactly which files still need jQuery. That makes the end of the project much easier.
If you're on an old version of jQuery, upgrade it with the jQuery Migrate plugin turned on. It prints a warning for each outdated call, which gives you a ready-made to-do list. We were already on 3.6, but we still found something old: handlers listening for 'propertychange input'. propertychange was only needed for old versions of Internet Explorer.
Running jQuery and React Together
If people use the page every day, we wouldn't replace it all in one go. It's safer to replace it bit by bit, so the new code slowly takes over from the old. Martin Fowler calls this the strangler fig pattern. jQuery makes it easy, because unlike a framework, it never takes over the whole page.
Each element gets one owner
Give React an empty element and start it there with createRoot. jQuery keeps running everything else. There's one rule you must follow: once React owns an element, jQuery must never touch anything inside it. If a stray .html() or .addClass() changes React's elements, nothing crashes. React simply doesn't know about the change and undoes it the next time it draws. These bugs are confusing to track down.
Watch out for handlers like $(document).on('click', '.share', …). They look for a class name anywhere on the page. So they'll also fire on buttons that React draws, if those buttons keep the old class names. And they should keep them, for the CSS and the tests. When you move part of the page to React, search for its class names in .on( calls and delete those handlers at the same time.
Wrapping a plugin you can't replace yet
Sometimes there's no good React version of a plugin yet, or replacing it would slow everything else down. In that case, wrap it. Give the plugin an element using a ref, create it in an effect, and destroy it in the cleanup:
import $ from 'jquery';
import 'jquery-ui/ui/widgets/datepicker';
export function DatePicker({ value, onChange }) {
const input = useRef(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
const $el = $(input.current);
$el.datepicker({ onSelect: (date) => onChangeRef.current(date) });
return () => $el.datepicker('destroy');
}, []);
useEffect(() => {
$(input.current).datepicker('setDate', value);
}, [value]);
return <input ref={input} readOnly />;
}
Three things here will save you some head-scratching:
- The cleanup must really remove the plugin. In development, React's Strict Mode adds the component, removes it and adds it again. If the plugin can't be removed, you'll get two of it.
-
Keep the latest
onChangein a ref. Otherwise the plugin keeps calling the first version of your function. - Don't let React draw anything inside the plugin's element. That part of the page belongs to the plugin. React only talks to it through the plugin's own methods.
Passing messages between the two
For a while, the jQuery code and the React code will need to tell each other things. To send a message from React to jQuery, trigger an event with $(document).trigger('tip:changed', [pct]) or a plain CustomEvent, and let the jQuery code listen for it. For values both sides need to read, keep them in one small shared store and read it in React with useSyncExternalStore. We used the same trick in our vanilla JavaScript migration. Both of these are temporary, and they're on the clean-up list at the end.
From Handlers to State
This is where we spent most of our time. Here's the original calculation and two of the handlers that use it, shortened a little:
var total = "0.00"
var people = 1
var tipPerc = 0.00
function calculation() {
$(".invalid").hide()
var totalAmount = parseFloat((total / people).toFixed(2))
var tipTot = parseFloat((total * tipPerc).toFixed(2))
var tipPerPerson = parseFloat((tipTot / people).toFixed(2))
totalAmount = (totalAmount + tipPerPerson).toFixed(2)
$("#tip-display").html(tipPerPerson)
$("#total-display").html(totalAmount)
}
$(".share").click(function () {
$(this).addClass("tip-selected").siblings().removeClass("tip-selected")
tipPerc = parseFloat(($(".tip-selected").val() / 100).toFixed(2))
calculation(); resetActive();
})
$(".shareinput").change(function () {
$(this).addClass("tip-selected").siblings().removeClass("tip-selected")
})
Look at the last handler. When you type a custom tip, the box gets highlighted, but tipPerc never changes. So the custom tip has never worked, not even once. Nobody meant to write that. It happens easily when highlighting a button and updating the number behind it are two separate jobs in two separate places. Once we knew to look for this, we found it all over the app:
- Reset only cleared the screen. The variables kept their old values. Type a new bill after a reset and it quietly used the old tip and the old number of people, even though no tip button was highlighted.
-
Some orders of typing showed
NaNorInfinity. If you typed the number of people before the bill, the empty bill box turned intoNaN. If you set people to 0 and then changed the bill, the error message disappeared and the app divided by zero. - The error message didn't clear the old results. With people set to 0, "Can't be zero" showed up next to totals that no longer made sense.
- Amounts were rounded too early. The bill and tip for each person were rounded separately and then added together. A 10.00 bill with a 10% tip, split three ways, showed 3.66 each instead of 3.67.
- Reset was listening in the wrong place. The click handler was on the box around the button, so clicking next to a greyed-out Reset button still cleared the form.
In React, we only store what the user typed. One reducer changes it, and everything else is worked out from it each time the page is drawn:
const [state, dispatch] = useReducer(reducer, initialState);
// state = { bill: '142.55', tip: { kind: 'preset', pct: 15 }, customTip: '', people: '5' }
const split = calculateSplit(parseFloat(state.bill), tipPercent(state), Number(state.people));
<span id="tip-display">{formatMoney(split?.tipPerPerson ?? 0)}</span>
<button className="reset-btn" disabled={!isDirty(state)} onClick={() => dispatch({ type: 'reset' })}>
Now each bug on that list has nowhere to come from:
- Reset puts the state back to the start. There's no second copy of the tip to forget about.
- A custom tip is just another kind of tip in state, so highlighting it and using it can't get out of sync.
-
calculateSplitreturns nothing unless the bill is a number and people is a whole number of at least one, soNaNandInfinitynever reach the screen. - It rounds once, at the very end.
- Whether Reset is enabled is worked out from state. No handler has to remember to switch it on.
If you remember one thing from this post, make it this. A jQuery handler asks, "What do I need to change now?" A React component asks, "What should the page look like right now?" The second question has only one answer, and you only have to get it right once.
What about animations and Ajax?
Our calculator didn't have either, but most jQuery apps have both.
-
Animations. Replace
.fadeIn()and.slideToggle()with a class set from state and a CSS transition. It usually looks smoother, and the timing lives in the CSS. Animating something as it disappears is harder, because it has to stay on the page until the animation ends. A small library like Motion handles that. -
Ajax. Replace
$.ajaxwithfetch, inside a data library like TanStack Query. It keeps track of loading and errors for you, which jQuery code usually does by hand. Look closely at.done()and.fail()callbacks that also change the page. In React, that part becomes drawing the page from the query's result.
Keep the HTML and the CSS
We kept every class name and id. That meant the original CSS worked almost as-is, and our tests could use the same selectors as the jQuery code. We only had to make two changes:
- The background icons moved to a new folder, so their paths in the CSS changed.
- The tip buttons had their labels inside
<h3>tags, which aren't allowed inside a<button>. We changed them to<span>tags and addeddisplay: block; font-weight: 700so they look exactly the same.
Like in our vanilla JavaScript migration, we added #root { display: contents } so React's extra wrapper element didn't break the page layout.
One jQuery detail caught us out. .show() adds an inline display style to the element. It turned out this inline style was beating a phone-only CSS rule that hides .bill span, and that rule also matches the error message. If you swap .show() for a class, the error message vanishes on phones. We used an inline style to match the original. Small things like this are why it's worth putting the old and new versions side by side and comparing them.
Testing the Migration
The original had no tests. So before changing anything, we wrote down what it actually does, strange parts included. Michael Feathers calls these characterisation tests in Working Effectively with Legacy Code. They describe how the code behaves today, not how anyone thinks it should behave.
Every bug in the list above became a test. The test either kept the old behaviour or recorded our decision to change it. We fixed all of them, and listed every change in the project README along with the things we kept on purpose. For example, when the people box has the cursor in it, its outline is teal instead of red, even when it shows an error. That comes from the order of two rules in the original CSS. We left it alone so the CSS stayed the same.
Keeping the class names helped again here. Our React tests click and type using the original jQuery selectors, like .bill-input, .share and #total-display. On a bigger app, that means the same Playwright tests can run on both the jQuery page and the React app. We only count something as moved when it passes on both.
We also took screenshots of both versions and compared them. On desktop they were pixel-for-pixel the same. On a phone-sized screen, the only difference was a slight blur on the edges of the text in one button.
One thing tripped us up in the React tests. Vitest doesn't load CSS unless you ask it to, so a test that checks whether the error message is visible can give the wrong answer. Turning on css: true fixed it. Now .invalid { display: none } is applied in the tests, and Testing Library's toBeVisible means what it says. We ended up with 19 tests covering the maths, the input checks, preset and custom tips, the "Can't be zero" error, filling the boxes in any order, and Reset.
Finishing the Migration
Helping the team get used to React
People who've written a lot of jQuery usually know the browser really well. That knowledge still counts. It's the habits that need to change. These are the ones we see most in people's first React pull requests:
-
Using
$inside a component. If something should look different, change the state and let React update the page. Only use jQuery for wrapped plugins, and only inside their effects. -
Adding listeners to
documentwith.on(). PutonClickand similar props on the element instead. If you really need a listener onwindowordocument, add it in an effect and remove it in the cleanup. -
Keeping values in variables or
.data()and updating them by hand. If a value can be worked out from state, work it out while drawing. The React docs on effects explain this well. -
Animating with JavaScript. Most of what
.animate()did can be a CSS transition.
Two things help more than any tutorial. First, a short internal page that shows one of your own jQuery handlers next to its React version. Second, for the first few weeks, have two people review each React pull request: one who knows the old page and one who knows React. They'll both learn something.
The clean-up checklist
You're finished when jQuery is no longer in your build. Before you delete it, go through this list:
- No code calls
$()on elements React draws, and no.on()handlers match React's class names. - Every shared variable is now state, or worked out from state.
- Every plugin has been replaced, or is wrapped in a component that removes it properly.
- All
$.ajaxcalls and their.done()/.fail()callbacks now usefetchand your data library. - No file imports
jqueryany more, and a search of the built files confirms it. - The CDN
<script>tag, jQuery Migrate, and the temporary events and store are all gone.
Keep the last jQuery version ready to switch back to for a little while. Once real users have been on the React version for a while with no problems, delete it.
Looking back, the code itself was simpler than in any framework migration we've done. The real work was finding every handler that was meant to keep something up to date, and spotting the ones that didn't. In short: list what jQuery does before you estimate, count plugins on their own, give React whole parts of the page at a time, store only what the user typed and work out the rest, and test both versions against what the page really does.
Try It Yourself: the jQuery to React Plugin
We've packaged the approach in this guide as a free, open-source plugin for Claude Code. jquery-to-react works on jQuery apps and pages, including jQuery plugins and jQuery UI, 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-jquery-to-react-migration |
The migration workflow: assess, choose a strategy, plan, then migrate one unit per run |
| Command | /jquery-to-react:plan |
Inventories the app and writes a migration plan to .migration/ for you to approve, without changing any app code |
| Agent | jquery-inventory |
A read-only sweep of the jQuery codebase: DOM changes, handlers, delegated events, Ajax, effects, .data(), plugins and shared variables |
| Agent | jquery-parity-reviewer |
Compares each migrated piece with its jQuery 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 jquery-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 jquery-to-react@cbx-plugins). The plugin README has the setup for each one.
Once it's installed, open your jQuery project and run /jquery-to-react:plan, or simply ask Claude something like "Migrate this jQuery app to React" or "Wrap this jQuery UI datepicker in 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 platform and service for moving software, data and infrastructure from one technology to another. It combines automation with experienced engineers. The automation does the repetitive work quickly, and the engineers make the tricky decisions.
This guide is how we actually run jQuery to React projects with MigrateX. Here's what that looks like:
- Assessment. We go through your pages and list every way they use jQuery: page changes, handlers, Ajax, animations and plugins, plus the shared variables that handlers keep updating. You see where the work is before anyone agrees on a timeline.
- Plan. We work with your team to decide which parts of each page move first, which plugins to replace and which to wrap for now, and what can simply be deleted.
- Migration, step by step. Automation handles the repetitive parts, like bundling jQuery, turning HTML into JSX and moving calculations into tested functions. Our engineers handle the parts that need thought, like state, plugins and the code that connects old and new.
- Proof that it works. We write down how your pages behave today, strange parts included, and run the same tests on both versions. A page only counts as moved when it passes on both.
- Handover and clean-up. We remove the temporary code and jQuery itself, keep a way to switch back until things are quiet, and leave your team with a React codebase they understand and own.
What you get along the way:
- No big launch day. Your site keeps running and getting new features while the move happens in the background.
- Less risk. Shared tests and a way to switch back mean users won't notice the move, and you can pause whenever you need to.
-
A team that's ready. We work alongside your developers the whole time, so they're comfortable with React well before the last
$is gone. - More than front ends. We use the same approach for Angular, Ember, Backbone and plain JavaScript, and for data and infrastructure moves too.
The code behind this guide is at github.com/cobuild-tech/migratex-examples. Got a jQuery page that grew into an app nobody wants to touch? We'd love to hear about it. Tell us a little about it through Contact Us, and we'll start with a free chat about where the work is likely to be.
Source code
- CobuildX AI. jquery-to-react: a Claude Code plugin for jQuery to React migrations. https://github.com/cobuild-tech/cbx-plugins/tree/main/jquery-to-react
- CobuildX AI. migratex-examples: a tip calculator built with HTML, CSS and jQuery 3.6, and its React 18 rewrite, with a README describing the migration. https://github.com/cobuild-tech/migratex-examples/tree/main/jquery-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.
jQuery and the web platform
- jQuery Migrate. https://github.com/jquery/jquery-migrate
- MDN. CustomEvent. https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent
- MDN. Using CSS transitions. https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions
React and testing
- React. createRoot. https://react.dev/reference/react-dom/client/createRoot
- React. StrictMode. https://react.dev/reference/react/StrictMode
- 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. About Queries. https://testing-library.com/docs/queries/about/
Libraries
- Motion. https://motion.dev/
- TanStack Query. https://tanstack.com/query/latest
- Vite. https://vitejs.dev/
Originally published on the CobuildX blog.






Top comments (0)