jQuery to React Migration Guide

Type: Software Reference Confidence: 0.90 Sources: 8 Verified: 2026-02-22 Freshness: quarterly

TL;DR

Constraints

Quick Reference

jQuery PatternReact EquivalentExample
$('#el').html(val)useState + JSXconst [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 + useEffectuseEffect(() => { fetch(url).then(...) }, [])
$('#el').animate()CSS transitions or Framer Motiontransition: 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 refsref.current.querySelector(...)
$('#form').serialize()FormData or controlled statenew FormData(formRef.current)
$('#el').show() / .hide()State-driven rendering{isVisible && <Component />}
$.Deferred()Promise / async/awaitconst data = await fetch(url).then(r => r.json())
$('#plugin').pluginName()useEffect + useRef wrapperSee 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

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

VersionStatusBreaking ChangesMigration Notes
React 19 (2024-12)CurrentforwardRef deprecated, ReactDOM.render removedUse ref as regular prop; createRoot exclusively. Codemod: npx codemod react/19/remove-forward-ref
React 18 (2022-03)LTSReactDOM.rendercreateRoot, automatic batchingReplace all ReactDOM.render() calls. Multiple roots fully supported.
React 17 (2020-10)MaintenanceNew JSX transform, event delegation to rootNo import React needed with new transform
React 16 (2017-09)EOLFiber reconciler, PortalsUpgrade path well-documented
jQuery 3.7 (2023)CurrentWorks alongside React; remove only after full migration
jQuery 2.xDeprecatedNo IE6-8 supportUpgrade to 3.x before migrating to React
jQuery 1.xEOLUpgrade to 3.x first; 1.x has known XSS vulnerabilities

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Building complex SPAs with rich interactive stateStatic sites with minimal interactivityVanilla JS or Alpine.js
Team needs component reusability across pagesPrototype or throwaway projectKeep jQuery
App has >10 dynamic UI widgetsjQuery codebase is <500 linesIncremental vanilla JS refactor
Server-side rendering is neededServer already renders perfect HTMLHotwire/Turbo, htmx
Hiring — React devs are easier to findTeam is productive with jQuery, no new features plannedMaintain as-is
Need TypeScript support for large codebasesBudget/timeline doesn’t allow migrationAdd TypeScript to jQuery code
Performance-critical UI with frequent re-rendersSimple form submissions and page navigationKeep jQuery or use htmx

Important Caveats

Related Units