jQuery to React Migration Guide
How do I migrate a jQuery codebase to React?
TL;DR
- Bottom line: Migrate incrementally using the strangler fig pattern — mount React components into jQuery-managed pages one at a time using
createRoot(), replace jQuery patterns with React state and hooks, then remove jQuery when no code depends on it. [src1, src3] - Key tool/command:
createRoot(document.getElementById('react-mount')).render(<App />) - Watch out for: Direct DOM manipulation inside React components — mixing
$(el).html()with React state causes desync bugs that are hard to trace. [src1] - Works with: React 18+/19, any jQuery version (1.x–3.x), Webpack, Vite, or plain script tags.
Constraints
- React 18+ requires
createRoot()fromreact-dom/client— the legacyReactDOM.render()was removed in React 19. All migration code must use the modern API. [src2, src7] - Never let jQuery and React manage the same DOM node. React’s virtual DOM reconciliation assumes it owns the subtree. If jQuery mutates a React-managed node, React loses track of state. [src1]
- jQuery plugins that attach event listeners must be wrapped in
useEffectwith a cleanup function that calls$el.off()and$el.plugin('destroy'). [src1] - jQuery 3.x
$.Deferredis not Promise/A+ compliant — convert all deferred-based AJAX to nativefetch+async/awaitbefore integrating with React state. [src5] - When jQuery removes a DOM container holding a React root, call
root.unmount()first to prevent detached DOM trees. [src2]
Quick Reference
| jQuery Pattern | React Equivalent | Example |
|---|---|---|
$('#el').html(val) | useState + JSX | const [text, setText] = useState('') → <p>{text}</p> |
$('#el').on('click', fn) | JSX event handler | <button onClick={handleClick}> |
$('#el').toggle() | Conditional rendering | {show && <Modal />} |
$('#el').addClass('active') | Dynamic className | <div className={active ? 'active' : ''}> |
$('#el').css({color: 'red'}) | Inline style object | <div style={{color: 'red'}}> |
$('#el').val() | Controlled input | <input value={val} onChange={e => setVal(e.target.value)} /> |
$.ajax({url, success}) | fetch + useEffect | useEffect(() => { fetch(url).then(...) }, []) |
$('#el').animate() | CSS transitions or Framer Motion | transition: opacity 0.3s |
$(document).ready(fn) | useEffect(() => {}, []) | Runs after component mounts |
$.each(arr, fn) | arr.map(item => <Li />) | {items.map(i => <Item key={i.id} {...i} />)} |
$('#el').find('.child') | useRef + child refs | ref.current.querySelector(...) |
$('#form').serialize() | FormData or controlled state | new FormData(formRef.current) |
$('#el').show() / .hide() | State-driven rendering | {isVisible && <Component />} |
$.Deferred() | Promise / async/await | const data = await fetch(url).then(r => r.json()) |
$('#plugin').pluginName() | useEffect + useRef wrapper | See jQuery Plugin Wrapper pattern |
Decision Tree
START
├── Is this a full rewrite or incremental migration?
│ ├── FULL REWRITE → Set up new React project (Vite/Next.js), rebuild from scratch
│ └── INCREMENTAL (strangler fig) ↓
├── How large is the jQuery codebase?
│ ├── SMALL (<5K LOC) → Migrate all at once over 1-2 sprints
│ └── MEDIUM/LARGE (>5K LOC) ↓
├── Does the page have isolated UI widgets (modals, tabs, forms)?
│ ├── YES → Start with "React Islands": mount React into existing page via createRoot()
│ └── NO ↓
├── Does the jQuery code heavily mutate shared global state?
│ ├── YES → First extract state into a shared store (Zustand/Context), then migrate UI
│ └── NO ↓
├── Are there jQuery plugins with no React equivalent?
│ ├── YES → Wrap them in a React component using useEffect + useRef
│ └── NO ↓
├── Does the app need server-side rendering?
│ ├── YES → Use Next.js as the migration target, migrate page-by-page
│ └── NO ↓
└── DEFAULT → Migrate page-by-page: replace jQuery selectors with React components
Step-by-Step Guide
1. Audit the jQuery surface area
Grep your codebase to quantify the migration scope. Count AJAX calls, event bindings, DOM manipulations, and plugin usages. This converts a vague rewrite into discrete, estimable tasks. [src3]
# Count jQuery usage patterns
grep -rn '\$\.' --include='*.js' --include='*.html' | wc -l
grep -rn '\$\.ajax\|\.get(\|\.post(' --include='*.js' | wc -l
grep -rn '\.on(\|\.click(\|\.submit(' --include='*.js' | wc -l
grep -rn '\.html(\|\.text(\|\.val(\|\.append(' --include='*.js' | wc -l
# List jQuery plugins used
grep -rn '\.pluginName\|\.datepicker\|\.chosen\|\.select2\|\.slick\|\.modal' --include='*.js' | sort -u
Verify: Review the counts — a typical medium app has 50–200 jQuery call sites. Prioritize by page traffic.
2. Set up React alongside jQuery
Install React into your existing project without removing jQuery. Both can coexist on the same page. [src1, src2]
npm install react react-dom
# If using Vite:
npm create vite@latest react-app -- --template react
# If adding to existing Webpack:
npm install @babel/preset-react --save-dev
Verify: import { createRoot } from 'react-dom/client' compiles without errors.
3. Create mount points in existing HTML
Add empty <div> containers where React components will render. jQuery continues to manage the rest. Each React “island” gets its own createRoot() call. [src1, src6]
import { createRoot } from 'react-dom/client';
import SearchWidget from './components/SearchWidget';
import Sidebar from './components/Sidebar';
// Multiple roots on the same page is fully supported [src2]
const searchContainer = document.getElementById('react-search-widget');
if (searchContainer) {
createRoot(searchContainer).render(<SearchWidget />);
}
const sidebarContainer = document.getElementById('react-sidebar');
if (sidebarContainer) {
createRoot(sidebarContainer).render(<Sidebar />);
}
Verify: React components render within the existing page without breaking jQuery functionality.
4. Replace jQuery DOM manipulation with React state
Convert imperative DOM updates to declarative React state. Stop reading from the DOM and start reading from state. [src5, src8]
// BEFORE: jQuery — DOM is the state
$('#counter').text(count);
$('#increment').on('click', function() {
count++;
$('#counter').text(count);
});
// AFTER: React — state drives the UI
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
Verify: Remove the jQuery code for that widget, confirm the React version behaves identically.
5. Replace $.ajax with fetch + useEffect
Convert AJAX calls to the modern fetch API with React hooks. Always include AbortController for cleanup. [src1, src5]
function UserList() {
const [users, setUsers] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
fetch('/api/users', { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => setUsers(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Verify: Network tab shows the same API requests as the old jQuery version.
6. Wrap jQuery plugins with no React equivalent
For plugins without React alternatives, wrap them using useRef and useEffect with cleanup. React owns the component lifecycle; jQuery owns only the plugin DOM inside the ref container. [src1]
function ChosenSelect({ options, value, onChange }) {
const selectRef = useRef(null);
useEffect(() => {
const $el = $(selectRef.current);
$el.chosen({ width: '100%' });
const handleChange = (e) => onChange(e.target.value);
$el.on('change', handleChange);
return () => {
$el.off('change', handleChange);
$el.chosen('destroy');
};
}, []);
useEffect(() => {
$(selectRef.current).val(value).trigger('chosen:updated');
}, [value]);
return (
<select ref={selectRef} value={value}>
{options.map(opt =>
<option key={opt.value} value={opt.value}>{opt.label}</option>
)}
</select>
);
}
Verify: Plugin initializes on mount, updates on prop changes, cleans up on unmount — no console errors or memory leaks.
7. Build a shared component library
Before migrating pages, extract common UI patterns into reusable React components. This prevents each page from reimplementing elements with inconsistent styles. [src3, src4]
// components/Button.jsx
function Button({ variant = 'primary', size = 'md', children, ...props }) {
return (
<button className={`btn btn-${variant} btn-${size}`} {...props}>
{children}
</button>
);
}
// components/Modal.jsx — replaces $.fn.modal
function Modal({ isOpen, onClose, title, children }) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
Verify: Import components into two different pages; confirm consistent styling and behavior.
8. Remove jQuery and clean up
Once all components are migrated, uninstall jQuery and remove script tags. [src3, src4]
npm uninstall jquery
grep -rn '\$(\|jQuery(' --include='*.js' --include='*.tsx' --include='*.jsx'
# Verify bundle no longer includes jQuery
npx vite build && du -h dist/assets/*.js | sort -rh | head -5
Verify: grep -rn 'jquery' --include='*.json' --include='*.js' returns zero results. App runs without jQuery loaded.
Code Examples
JavaScript/React: Full page migration from jQuery to React
Full script: javascript-react-full-page-migration-from-jquery-t.js (47 lines)
// Input: A jQuery page with search form, results list, and loading spinner
// Output: Equivalent React component with hooks
import { useState, useCallback } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSearch = useCallback(async (e) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setResults(data.items);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [query]);
return (
<div>
<form onSubmit={handleSearch}>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />
<button type="submit" disabled={loading}>
{loading ? 'Searching...' : 'Search'}
</button>
</form>
{error && <p className="error">{error}</p>}
<ul>{results.map(item => <li key={item.id}>{item.title}</li>)}</ul>
</div>
);
}
export default SearchPage;
TypeScript/React: Bridge store for jQuery-React coexistence
Full script: typescript-react-bridging-jquery-and-react-during-.ts (33 lines)
// Input: A legacy jQuery page where React needs to read/write shared state
// Output: A bridge utility that lets jQuery and React share data
import { useSyncExternalStore } from 'react';
class BridgeStore<T> {
private state: T;
private listeners = new Set<() => void>();
constructor(initialState: T) { this.state = initialState; }
getState = (): T => this.state;
setState(partial: Partial<T>): void {
this.state = { ...this.state, ...partial };
this.listeners.forEach(l => l());
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
// jQuery: window.appStore = new BridgeStore({ user: null });
// jQuery: window.appStore.setState({ user: { name: 'Alice' } });
function useBridgeStore<T>(store: BridgeStore<T>): T {
return useSyncExternalStore(store.subscribe, store.getState);
}
Python/Flask: Migrating server-rendered jQuery pages to React API
Full script: python-flask-migrating-server-rendered-jquery-page.py (25 lines)
# Input: Flask app that renders HTML with inline jQuery
# Output: Same app refactored to serve React SPA with JSON API
from flask import Flask, jsonify, send_from_directory
from flask_cors import CORS
app = Flask(__name__, static_folder='react-app/dist')
CORS(app)
@app.route('/api/users')
def api_users():
users = db.get_users()
return jsonify([{'id': u.id, 'name': u.name} for u in users])
# Serve React SPA for all non-API routes
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve_react(path):
if path and (app.static_folder / path).exists():
return send_from_directory(app.static_folder, path)
return send_from_directory(app.static_folder, 'index.html')
JavaScript: React root lifecycle management for jQuery tab panels
// Input: jQuery tab panel that dynamically removes/adds DOM nodes
// Output: React roots that properly mount/unmount with jQuery lifecycle [src2]
const roots = new Map();
function mountReactInTab(tabId, Component) {
const container = document.getElementById(`react-${tabId}`);
if (!container) return;
const root = createRoot(container);
root.render(<Component />);
roots.set(tabId, root);
}
function unmountReactInTab(tabId) {
const root = roots.get(tabId);
if (root) {
root.unmount();
roots.delete(tabId);
}
}
// jQuery tab change handler
$('#tabs').on('tabchange', function(e, { deactivated, activated }) {
unmountReactInTab(deactivated);
mountReactInTab(activated, tabComponents[activated]);
});
Anti-Patterns
Wrong: Direct DOM manipulation inside React components
// BAD — jQuery DOM manipulation inside a React component
function Counter() {
const handleClick = () => {
const current = parseInt($('#count').text());
$('#count').text(current + 1);
};
return (
<div>
<span id="count">0</span>
<button onClick={handleClick}>+1</button>
</div>
);
}
Correct: Use React state for all UI updates
// GOOD — React state drives the UI
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
Wrong: jQuery event delegation alongside React
// BAD — jQuery event delegation in React
useEffect(() => {
$(document).on('click', '.list-item', function() {
$(this).toggleClass('selected');
});
}, []);
Correct: Handle events in React with state
// GOOD — React handles events and state
function ListItem({ item }) {
const [selected, setSelected] = useState(false);
return (
<li className={selected ? 'list-item selected' : 'list-item'}
onClick={() => setSelected(s => !s)}>
{item.name}
</li>
);
}
Wrong: $.ajax without cleanup
// BAD — No cancellation, state update on unmounted component
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
$.ajax({ url: `/api/users/${userId}`, success: (data) => setUser(data) });
}, [userId]);
return <div>{user?.name}</div>;
}
Correct: fetch with AbortController cleanup
// GOOD — Proper cleanup prevents memory leaks
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => { if (err.name !== 'AbortError') console.error(err); });
return () => controller.abort();
}, [userId]);
return <div>{user?.name}</div>;
}
Wrong: Wrapping entire jQuery pages in React
// BAD — Loading an entire jQuery page inside React
function LegacyPage() {
useEffect(() => {
$('#legacy-container').load('/old-page.html', function() {
initAllPlugins();
});
}, []);
return <div id="legacy-container" />;
}
Correct: Migrate granular components, not entire pages
// GOOD — Replace one widget at a time
const container = document.getElementById('search-mount');
if (container) {
createRoot(container).render(<SearchWidget />);
}
// Rest of the page is still managed by jQuery
Wrong: Skipping component library before page migration
// BAD — Each page reimplements UI from scratch
// Page1: <button style={{background: 'blue'}}>Save</button>
// Page2: <button style={{background: '#0066ff'}}>Submit</button>
Correct: Build reusable components first
// GOOD — Shared component library
function Button({ variant = 'primary', children, ...props }) {
return <button className={`btn btn-${variant}`} {...props}>{children}</button>;
}
// All pages: <Button>Save</Button>
Wrong: Not unmounting React roots before jQuery removes containers
// BAD — jQuery removes DOM with active React root inside
$('#tabs').on('hide', function(e) {
$(e.target).find('.tab-content').remove(); // React root orphaned!
});
Correct: Unmount React root before jQuery DOM removal
// GOOD — Clean up React root first [src2]
$('#tabs').on('hide', function(e) {
const tabId = $(e.target).data('tab-id');
const root = reactRoots.get(tabId);
if (root) {
root.unmount();
reactRoots.delete(tabId);
}
$(e.target).find('.tab-content').remove();
});
Common Pitfalls
- React and jQuery both managing the same DOM node: React’s virtual DOM and jQuery’s direct DOM manipulation conflict, causing UI state to desync. Fix: Give each library its own DOM subtree — React renders into mount points that jQuery never touches. [src1]
- Forgetting useEffect cleanup for jQuery plugins: jQuery plugins attach event listeners that persist after React unmounts, causing memory leaks. Fix: Always return a cleanup function:
return () => { $el.off(); $el.plugin('destroy'); }. [src1] - Big-bang rewrite instead of incremental migration: Stalls feature development and introduces regression risk. A 50K LOC jQuery app takes 6–12 months for incremental migration vs. 12–18 months for a rewrite with higher failure risk. Fix: Migrate one component/page at a time and ship each to production before starting the next. [src3, src6]
- Not extracting state from the DOM first: In jQuery apps, the DOM is the state (hidden inputs, data attributes, element classes). Fix: Before migrating UI, extract implicit state into JavaScript variables or a shared store (Zustand, Context API). [src4, src8]
- Using $(document).ready() with React: Creates unnecessary coupling. Fix: Use
createRootdirectly in your entry point — React handles its own mounting lifecycle. [src5] - Mixing jQuery AJAX with React state without cancellation: Responses arriving after unmount cause warnings. Fix: Use
AbortControllerwithfetchor return a cancellation flag inuseEffectcleanup. [src1, src5] - Ignoring React 19 ref changes: React 19 deprecated
forwardRef— ref is now a regular prop. If your jQuery plugin wrappers useforwardRef, plan to update them. Fix: Passrefdirectly as a prop; codemod available:npx codemod react/19/remove-forward-ref. [src7] - Not tracking migration progress: Without metrics, migrations stall at 70% completion. Fix: Track jQuery call sites remaining (via grep counts) in CI and set sprint-level targets. [src3]
Diagnostic Commands
# Count remaining jQuery references in the codebase
grep -rn '\$(\|jQuery\|\.ajax\|\.on(' --include='*.js' --include='*.jsx' --include='*.tsx' | wc -l
# Find jQuery script tags in HTML files
grep -rn 'jquery\|jQuery' --include='*.html' | grep -i 'script'
# Check bundle size for jQuery (Webpack)
npx webpack --stats --json | jq '.assets[] | select(.name | contains("jquery"))'
# List all React mount points
grep -rn 'createRoot' --include='*.js' --include='*.jsx' --include='*.tsx' | wc -l
# Find components still importing jQuery
grep -rn "import.*jquery\|require.*jquery" --include='*.js' --include='*.jsx' --include='*.tsx'
# Verify React is rendering (browser console)
# document.querySelectorAll('[data-reactroot]').length
# Check for memory leaks from jQuery plugins (Chrome DevTools)
# Performance tab → Record → Interact → Check heap snapshots
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| React 19 (2024-12) | Current | forwardRef deprecated, ReactDOM.render removed | Use ref as regular prop; createRoot exclusively. Codemod: npx codemod react/19/remove-forward-ref |
| React 18 (2022-03) | LTS | ReactDOM.render → createRoot, automatic batching | Replace all ReactDOM.render() calls. Multiple roots fully supported. |
| React 17 (2020-10) | Maintenance | New JSX transform, event delegation to root | No import React needed with new transform |
| React 16 (2017-09) | EOL | Fiber reconciler, Portals | Upgrade path well-documented |
| jQuery 3.7 (2023) | Current | — | Works alongside React; remove only after full migration |
| jQuery 2.x | Deprecated | No IE6-8 support | Upgrade to 3.x before migrating to React |
| jQuery 1.x | EOL | — | Upgrade to 3.x first; 1.x has known XSS vulnerabilities |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building complex SPAs with rich interactive state | Static sites with minimal interactivity | Vanilla JS or Alpine.js |
| Team needs component reusability across pages | Prototype or throwaway project | Keep jQuery |
| App has >10 dynamic UI widgets | jQuery codebase is <500 lines | Incremental vanilla JS refactor |
| Server-side rendering is needed | Server already renders perfect HTML | Hotwire/Turbo, htmx |
| Hiring — React devs are easier to find | Team is productive with jQuery, no new features planned | Maintain as-is |
| Need TypeScript support for large codebases | Budget/timeline doesn’t allow migration | Add TypeScript to jQuery code |
| Performance-critical UI with frequent re-renders | Simple form submissions and page navigation | Keep jQuery or use htmx |
Important Caveats
- React 18+ requires
createRoot()instead of the deprecatedReactDOM.render(). React 19 removedReactDOM.render()entirely. All code examples use the modern API. [src2, src7] - jQuery 3.x uses
jQuery.Deferredwhich is not Promise/A+ compliant — convert to native Promises or async/await when migrating AJAX calls. [src5] - Some jQuery plugins modify DOM nodes that React also manages, causing reconciliation errors. Always isolate plugin DOM from React-managed DOM using a separate ref container. [src1]
- Bundle size impact: adding React (~44 KB gzipped for react + react-dom) alongside jQuery (~30 KB) temporarily increases page weight by ~74 KB. Remove jQuery as soon as all references are migrated. [src3]
- CSS-in-JS libraries (styled-components, Emotion) are optional — you can keep existing CSS/SCSS and use
classNameprops in React components. - React 19 deprecated
forwardRef. If jQuery plugin wrappers usedforwardRef, update them to passrefas a regular prop. Codemod:npx codemod react/19/remove-forward-ref. [src7] - During the coexistence period, avoid running React’s StrictMode for components that wrap jQuery plugins — double-invocation of effects will initialize/destroy plugins twice. [src1]
- For very large jQuery codebases (>100K LOC) with multiple team domains, consider Webpack 5 Module Federation to lazy-load entire React microfrontends at runtime while jQuery still serves the shell. This adds governance complexity but lets independent teams ship React migrations without coordinating a single bundle. [src9]
- 2026 industry data: AI-assisted code analysis (AST-level pattern matching plus LLM code generation) is shortening incremental migrations from ~12 months to ~4–7 months for medium-sized apps. Treat the codemod step as automatable but the architecture-decision step (which widget becomes which component) as still human-led. [src9]