Many WordPress themes still carry years of jQuery: an AJAX filter here, a tabs widget there, a modal that nobody wants to touch. Rewriting the whole frontend in one go is risky and expensive. Converting one feature at a time to React, inside the existing theme, gets you most of the benefit with almost no risk.
This guide walks through that incremental approach: the build setup, mounting components, passing data securely and replacing a typical jQuery AJAX filter.
When it's worth migrating a featureLink to this section
Migrate a jQuery feature to React when it has state: filters, multi-step forms, calculators, live search or anything where the UI depends on several values at once. jQuery handles that through manual DOM updates that grow fragile. React's "UI is a function of state" model removes that whole class of bugs.
Leave simple behaviour alone. A mobile menu toggle doesn't need React; often it doesn't need JavaScript at all, because <details> or a CSS-only pattern will do.
Build setup with @wordpress/scriptsLink to this section
@wordpress/scripts gives you a zero-config webpack and Babel setup that matches WordPress core:
npm init -y
npm install --save-dev @wordpress/scripts
npm install @wordpress/element @wordpress/html-entities{
"scripts": {
"build": "wp-scripts build src/property-filter.js --output-path=build",
"start": "wp-scripts start src/property-filter.js --output-path=build"
}
}The build produces build/property-filter.js and build/property-filter.asset.php. The asset file lists dependencies such as wp-element and a version hash. Because @wordpress/element is provided by WordPress itself, your bundle doesn't ship its own copy of React.
Enqueue the bundle and pass data safelyLink to this section
Load the script only on the page that uses it, and pass configuration through wp_add_inline_script():
add_action( 'wp_enqueue_scripts', function () {
if ( ! is_post_type_archive( 'property' ) ) {
return;
}
$asset = include get_theme_file_path( 'build/property-filter.asset.php' );
wp_enqueue_script(
'acme-property-filter',
get_theme_file_uri( 'build/property-filter.js' ),
$asset['dependencies'],
$asset['version'],
array( 'in_footer' => true, 'strategy' => 'defer' )
);
wp_add_inline_script(
'acme-property-filter',
'window.acmeFilter = ' . wp_json_encode( array(
'restUrl' => esc_url_raw( rest_url( 'wp/v2/property' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
) ) . ';',
'before'
);
} );The wp_rest nonce lets logged-in users make authenticated REST requests, sent as the X-WP-Nonce header. For public, read-only data it isn't strictly required, but passing it keeps one pattern for both cases.
The theme template only needs a mount point, with server-rendered fallback content inside it so the page works before the script loads:
<div id="property-filter" data-per-page="12">
<?php get_template_part( 'parts/property-grid' ); ?>
</div>The jQuery version we're replacingLink to this section
jQuery(function ($) {
$('#location').on('change', function () {
$('#results').html('<p>Loading…</p>');
$.get(acmeFilter.restUrl, { location: this.value, per_page: 12 }, function (items) {
var html = '';
items.forEach(function (p) { html += '<li><a href="' + p.link + '">' + p.title.rendered + '</a></li>'; });
$('#results').html('<ul>' + html + '</ul>');
});
});
});It works, but it has three problems. Responses can arrive out of order and show stale results, nothing handles errors, and building HTML strings from API data invites XSS if a title ever contains markup.
Try it yourself: why HTML strings are riskyLink to this section
This example renders the same post title twice: once by concatenating it into innerHTML (as the jQuery code does) and once with textContent. A title containing markup becomes live HTML in the first list.
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 1rem; }
li { margin: .25rem 0; }
.bad { color: #a3261b; } .good { color: #1f6b3a; }
</style>
<p>A title from the API contains markup:</p>
<h3 class="bad">innerHTML (string building)</h3>
<ul id="unsafe"></ul>
<h3 class="good">textContent (what React does by default)</h3>
<ul id="safe"></ul>
<script>
const items = [
{ title: 'Villa in Nagole' },
{ title: 'Penthouse <img src=x onerror="document.body.style.background=\'#fdd\'">' },
];
// Unsafe: markup in the title is parsed and the onerror handler runs.
document.getElementById('unsafe').innerHTML = items.map(i => '<li>' + i.title + '</li>').join('');
// Safe: the title is treated as text.
for (const i of items) {
const li = document.createElement('li');
li.textContent = i.title;
document.getElementById('safe').appendChild(li);
}
</script>The React versionLink to this section
// src/property-filter.js
import { createRoot, useEffect, useState } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
function PropertyFilter( { perPage } ) {
const [ location, setLocation ] = useState( '' );
const [ items, setItems ] = useState( [] );
const [ status, setStatus ] = useState( 'idle' );
useEffect( () => {
const controller = new AbortController();
const url = new URL( window.acmeFilter.restUrl );
url.searchParams.set( 'per_page', perPage );
if ( location ) url.searchParams.set( 'location', location );
setStatus( 'loading' );
fetch( url, { signal: controller.signal, headers: { 'X-WP-Nonce': window.acmeFilter.nonce } } )
.then( ( r ) => ( r.ok ? r.json() : Promise.reject( r.status ) ) )
.then( ( data ) => { setItems( data ); setStatus( 'done' ); } )
.catch( ( err ) => { if ( err.name !== 'AbortError' ) setStatus( 'error' ); } );
return () => controller.abort(); // cancels the previous request
}, [ location, perPage ] );
return (
<div>
<label htmlFor="pf-location">Location</label>
<select id="pf-location" value={ location } onChange={ ( e ) => setLocation( e.target.value ) }>
<option value="">All locations</option>
<option value="12">Nagole</option>
<option value="14">Pocharam</option>
</select>
<p aria-live="polite" className="screen-reader-text">
{ status === 'done' ? `${ items.length } properties found` : '' }
</p>
{ status === 'error' && <p role="alert">Listings couldn’t load. Please try again.</p> }
<ul aria-busy={ status === 'loading' }>
{ items.map( ( p ) => (
<li key={ p.id }>
<a href={ p.link }>{ decodeEntities( p.title.rendered.replace( /<[^>]+>/g, '' ) ) }</a>
</li>
) ) }
</ul>
</div>
);
}
const el = document.getElementById( 'property-filter' );
if ( el ) {
createRoot( el ).render( <PropertyFilter perPage={ Number( el.dataset.perPage ) || 12 } /> );
}What improved:
- No race conditions.
AbortControllercancels the previous request whenever the filter changes. - Safe rendering. React escapes text, so a title can't inject HTML. Rendered titles can contain inline tags such as
<em>and entities such as’, so the code strips tags and then decodes entities with@wordpress/html-entities. Check patterns like this in the regex tester. - Clear states for loading, empty and error.
- Accessible feedback through an
aria-liveregion that announces the result count to screen-reader users.
In a real build, populate the location options from the taxonomy endpoint (/wp/v2/location) instead of hard-coding IDs. The content modeling guide explains the property type and location taxonomy this example assumes. Inspect API responses with the JSON formatter while you build.
Accessibility doesn't come free with ReactLink to this section
React makes state easier, not accessibility automatic. Keep these habits:
- Use real
<button>,<a>,<label>and<select>elements, not clickabledivs. - Move focus sensibly when content changes, for example to the results heading after a filter.
- Announce async results with
aria-live. - Check that text and controls meet WCAG contrast, especially for disabled and placeholder states. The contrast checker gives you the exact ratio.
Try it yourself: focus and announcementsLink to this section
Two details React won't add for you: a visible focus style and an aria-live region that tells screen-reader users the results changed. Tab through the buttons in the playground to see the focus ring.
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 1rem; }
button { font: inherit; padding: .5rem 1rem; border-radius: 8px; border: 1px solid #889; background: #fff; margin-right: .5rem; }
button:focus-visible { outline: 3px solid #2343c7; outline-offset: 2px; }
[aria-live] { margin-top: 1rem; color: #345; }
</style>
<button type="button" data-loc="Nagole">Nagole</button>
<button type="button" data-loc="Pocharam">Pocharam</button>
<p aria-live="polite" id="status">Choose a location.</p>
<script>
document.querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
const n = Math.floor(Math.random() * 12) + 1;
document.getElementById('status').textContent = n + ' properties found in ' + b.dataset.loc + '.';
}));
</script>Common problems during the switchLink to this section
403 rest_cookie_invalid_noncefor some visitors. The nonce in the page came from a cached copy and has expired. WordPress nonces are valid for roughly a day. For public, read-only requests, don't send the nonce at all; for authenticated actions, exclude those pages from the page cache or fetch a fresh nonce from an uncached endpoint.- jQuery and React fighting over the same elements. A jQuery plugin that still changes elements inside the React mount point will be overwritten on the next render, or will break React's updates. Give React its own container, and remove the old jQuery handlers for that feature in the same release.
- Content jumps when the component mounts.
createRootreplaces the server-rendered fallback with React's first render. Keep the first render the same size as the fallback (the same grid, or a skeleton with fixed dimensions) to avoid layout shift. - "React is not defined" or duplicate React. The script was enqueued without the dependencies from
property-filter.asset.php. Always pass$asset['dependencies'], sowp-elementloads first and WordPress's copy of React is reused. - The build works locally but not on the server. The
build/folder wasn't deployed. Either commit build output or runnpm run buildin the deployment step, but decide which and document it.
Retiring jQueryLink to this section
Once the last jQuery-dependent feature is converted, check whether any plugin still needs it. WordPress only loads jQuery when a script depends on it, so removing the dependency from your theme's scripts is often enough to drop roughly 30 KB of compressed JavaScript from every page.
Where this leadsLink to this section
Incremental React components are often the first step toward a fully headless frontend. Once the interactive parts already speak to the REST API, moving rendering to Next.js is a smaller leap. The headless WordPress with Next.js guide covers that next step, and ToolyConvert shows where it ends up: hundreds of interactive tools in React, with WordPress still managing the articles.
Frequently asked questionsLink to this section
Do I have to rewrite the whole theme in React?Link to this section
No. That's the point of the incremental approach. Each feature is converted and released on its own, and the theme keeps rendering everything else in PHP. Many sites stop after converting three or four interactive features and never need more.
Should I use plain JavaScript instead?Link to this section
For simple behaviour, yes. Modern browser APIs cover what jQuery was used for: querySelector, fetch, classList and event listeners. React earns its place when a feature has several pieces of state that affect each other.
Does this make the page heavier?Link to this section
A little. Your own bundle stays small because React isn't bundled into it: WordPress provides React through the shared wp-element script, about 40 to 50 KB compressed, which loads only on pages that enqueue a script depending on it. Keep each feature's script limited to the pages that use it, and that cost stays off the rest of the site.
Have a jQuery-heavy theme that's become hard to change? Tell me about it and I'll suggest which features to convert first.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.