Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 079 / 100

WordPress and React: a practical guide for developers

Use React inside WordPress.

Expona Admin11 min read

Most developers think React and WordPress don't mix well, but WordPress actually ships with React built-in for blocks and admin interfaces. We can leverage that same infrastructure to build sophisticated settings pages, custom admin experiences, and interactive features without fighting the platform. This guide shows you how to use React inside WordPress effectively, drawing on production patterns we've used across CRM and SaaS projects.

@wordpress/element — WordPress ships React for blocks and admin

WordPress includes React as a core dependency via the @wordpress/element package. Instead of bundling your own version, you're using the exact same React that powers the block editor, Gutenberg, and WordPress admin interfaces. This is a significant advantage: smaller bundle sizes, guaranteed compatibility, and alignment with how WordPress itself works.

When you enqueue scripts in WordPress, you declare wp-element as a dependency. WordPress then injects the React and ReactDOM libraries into your code at runtime. Here's how you'd register a script that depends on WordPress React:

php
function my_plugin_enqueue_admin_assets() {
    wp_register_script(
        'my-admin-app',
        plugins_url( 'dist/admin.js', __FILE__ ),
        array( 'wp-element', 'wp-dom-ready' ),
        filemtime( plugin_dir_path( __FILE__ ) . 'dist/admin.js' )
    );
    wp_enqueue_script( 'my-admin-app' );
}
add_action( 'admin_enqueue_scripts', 'my_plugin_enqueue_admin_assets' );

The key insight here is that wp-element re-exports React's API. When you build your admin interface with tools like Webpack or Vite, you import React from @wordpress/element:

php
import { render, useState } from '@wordpress/element';
import { Button } from '@wordpress/components';

function MyApp() {
    const [ count, setCount ] = useState( 0 );
    return (
        <div>
            <p>Count: { count }</p>
            <Button
                variant="primary"
                onClick={ () => setCount( count + 1 ) }
            >
                Increment
            </Button>
        </div>
    );
}

render( <MyApp />, document.getElementById( 'my-app-root' ) );

The build tool resolves @wordpress/element to an external dependency, so React doesn't get bundled into your code. WordPress injects it globally, keeping your plugin lean. We've used this pattern in production CRM integrations where the admin UI needs to be responsive without adding kilobytes of overhead.

Admin apps — Build settings pages with React components

A common workflow is building a plugin settings page with interactive controls. Rather than server-rendering forms in PHP and reloading the page on submit, React lets you build a seamless, client-side experience while keeping data synchronized with WordPress.

Start with a PHP-registered options page:

php
function my_plugin_add_admin_page() {
    add_menu_page(
        'My Plugin Settings',
        'My Plugin',
        'manage_options',
        'my-plugin-settings',
        'my_plugin_render_admin_page',
        'dashicons-cog'
    );
}
add_action( 'admin_menu', 'my_plugin_add_admin_page' );

function my_plugin_render_admin_page() {
    echo '<div id="my-plugin-app"></div>';
}

Then in your React component, you fetch and update settings via the WordPress REST API. We use the @wordpress/api-fetch package to handle this:

php
import { useState, useEffect } from '@wordpress/element';
import { Button, TextControl } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';

function SettingsApp() {
    const [ apiKey, setApiKey ] = useState( '' );
    const [ loading, setLoading ] = useState( false );
    const [ saved, setSaved ] = useState( false );

    useEffect( () => {
        apiFetch( { path: '/my-plugin/v1/settings' } )
            .then( ( data ) => setApiKey( data.api_key ) )
            .catch( ( error ) => console.error( error ) );
    }, [] );

    const handleSave = async () => {
        setLoading( true );
        try {
            await apiFetch( {
                path: '/my-plugin/v1/settings',
                method: 'POST',
                data: { api_key: apiKey }
            } );
            setSaved( true );
            setTimeout( () => setSaved( false ), 2000 );
        } catch ( error ) {
            console.error( error );
        } finally {
            setLoading( false );
        }
    };

    return (
        <div style={ { padding: '20px' } }>
            <h1>Settings</h1>
            <TextControl
                label="API Key"
                value={ apiKey }
                onChange={ setApiKey }
            />
            <Button
                variant="primary"
                onClick={ handleSave }
                isBusy={ loading }
            >
                Save
            </Button>
            { saved && <p>Settings saved!</p> }
        </div>
    );
}

On the backend, you'd register a REST endpoint to handle the fetch:

php
function my_plugin_register_rest_routes() {
    register_rest_route(
        'my-plugin/v1',
        'settings',
        array(
            'methods' => 'GET',
            'callback' => function() {
                return array(
                    'api_key' => get_option( 'my_plugin_api_key' )
                );
            },
            'permission_callback' => function() {
                return current_user_can( 'manage_options' );
            }
        )
    );

    register_rest_route(
        'my-plugin/v1',
        'settings',
        array(
            'methods' => 'POST',
            'callback' => function( $request ) {
                $params = $request->get_json_params();
                update_option( 'my_plugin_api_key', sanitize_text_field( $params['api_key'] ) );
                return array( 'success' => true );
            },
            'permission_callback' => function() {
                return current_user_can( 'manage_options' );
            }
        )
    );
}
add_action( 'rest_api_init', 'my_plugin_register_rest_routes' );

This pattern scales well for complex settings pages. We've built e-commerce integration panels this way—users can toggle sync settings, view logs, and trigger manual syncs without page reloads. The React component becomes a thin UI layer over a clean REST API.

@wordpress/components — Ready-made UI components

WordPress provides a library of tested, accessible UI components through @wordpress/components. Using these components ensures your admin interface looks consistent with WordPress and follows accessibility standards.

Common components include Button, TextControl, SelectControl, ToggleControl, Card, Modal, and Notice. They handle keyboard navigation, ARIA labels, and responsive behavior out of the box.

Here's a more realistic settings form combining several components:

php
import { useState } from '@wordpress/element';
import {
    Button,
    Card,
    CardBody,
    CardFooter,
    CardHeader,
    Notice,
    SelectControl,
    TextControl,
    ToggleControl
} from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';

function IntegrationSettings() {
    const [ provider, setProvider ] = useState( 'stripe' );
    const [ enabled, setEnabled ] = useState( false );
    const [ secret, setSecret ] = useState( '' );
    const [ notice, setNotice ] = useState( null );
    const [ saving, setSaving ] = useState( false );

    const handleSave = async () => {
        setSaving( true );
        try {
            await apiFetch( {
                path: '/my-plugin/v1/integration',
                method: 'POST',
                data: { provider, enabled, secret }
            } );
            setNotice( { type: 'success', message: 'Integration saved.' } );
        } catch ( error ) {
            setNotice( { type: 'error', message: 'Failed to save.' } );
        } finally {
            setSaving( false );
        }
    };

    return (
        <Card>
            <CardHeader>
                <h2>Payment Integration</h2>
            </CardHeader>
            <CardBody>
                { notice && (
                    <Notice
                        status={ notice.type }
                        isDismissible
                        onRemove={ () => setNotice( null ) }
                    >
                        { notice.message }
                    </Notice>
                ) }
                <SelectControl
                    label="Provider"
                    value={ provider }
                    options={ [
                        { label: 'Stripe', value: 'stripe' },
                        { label: 'PayPal', value: 'paypal' }
                    ] }
                    onChange={ setProvider }
                />
                <TextControl
                    label="Secret Key"
                    type="password"
                    value={ secret }
                    onChange={ setSecret }
                />
                <ToggleControl
                    label="Enable live mode"
                    checked={ enabled }
                    onChange={ setEnabled }
                />
            </CardBody>
            <CardFooter>
                <Button
                    variant="primary"
                    onClick={ handleSave }
                    isBusy={ saving }
                >
                    Save Settings
                </Button>
            </CardFooter>
        </Card>
    );
}

The component library handles all the UI concerns, so you focus on logic and state. In a travel booking plugin we worked on, we used these components to build a complex filter UI for availability settings—the consistency and accessibility it provided saved us weeks of CSS and testing.

Data stores — @wordpress/data manages state

For apps with shared state across multiple components, the @wordpress/data package provides a Redux-like store mechanism. If you've used Redux, this will feel familiar. WordPress uses this internally for block editor state.

You define a store with reducers, selectors, and actions:

php
import { createReduxStore, register } from '@wordpress/data';

const DEFAULT_STATE = {
    settings: {},
    loading: false,
    error: null
};

const store = createReduxStore( 'my-plugin', {
    reducer( state = DEFAULT_STATE, action ) {
        switch ( action.type ) {
            case 'SET_SETTINGS':
                return { ...state, settings: action.payload };
            case 'SET_LOADING':
                return { ...state, loading: action.payload };
            case 'SET_ERROR':
                return { ...state, error: action.payload };
            default:
                return state;
        }
    },
    selectors: {
        getSettings( state ) {
            return state.settings;
        },
        isLoading( state ) {
            return state.loading;
        },
        getError( state ) {
            return state.error;
        }
    },
    actions: {
        setSettings( settings ) {
            return { type: 'SET_SETTINGS', payload: settings };
        },
        setLoading( loading ) {
            return { type: 'SET_LOADING', payload: loading };
        },
        setError( error ) {
            return { type: 'SET_ERROR', payload: error };
        },
        *fetchSettings() {
            yield { type: 'SET_LOADING', payload: true };
            try {
                const response = yield apiFetch( {
                    path: '/my-plugin/v1/settings'
                } );
                yield { type: 'SET_SETTINGS', payload: response };
            } catch ( error ) {
                yield { type: 'SET_ERROR', payload: error.message };
            } finally {
                yield { type: 'SET_LOADING', payload: false };
            }
        }
    }
} );

register( store );

Then consume the store in your components using the useSelect and useDispatch hooks:

php
import { useSelect, useDispatch } from '@wordpress/data';
import { Spinner } from '@wordpress/components';

function SettingsDisplay() {
    const { settings, loading, error } = useSelect( ( select ) => {
        const store = select( 'my-plugin' );
        return {
            settings: store.getSettings(),
            loading: store.isLoading(),
            error: store.getError()
        };
    } );

    const { fetchSettings } = useDispatch( 'my-plugin' );

    useEffect( () => {
        fetchSettings();
    }, [] );

    if ( loading ) return <Spinner />;
    if ( error ) return <p>Error: { error }</p>;

    return (
        <pre>{ JSON.stringify( settings, null, 2 ) }</pre>
    );
}

The store pattern becomes essential when you have multiple components needing the same data. In a SaaS integration we built, the data store managed API credentials, webhook configurations, and sync logs—three separate React components read and updated the store without prop drilling.

Common mistakes

  • Bundling React separately: Don't import React from the npm package. Always use @wordpress/element. The build tool's externals config should prevent React from being bundled twice.
  • Skipping nonce validation: Always include _wpnonce in your REST API calls and verify it server-side. Omitting this opens your plugin to CSRF attacks. Use wp_create_nonce() in PHP and pass it to the React app via wp_localize_script().
  • Not sanitizing and escaping: Just because you're using a framework doesn't mean you skip WordPress security practices. Sanitize input server-side, escape output, and validate permissions on every endpoint.
  • Building without WordPress dependency array: When you register scripts, explicitly list dependencies like wp-element, wp-api-fetch, wp-data. Missing dependencies cause runtime errors when scripts load out of order.
  • Overcomplicating component hierarchies: React's simplicity is powerful. Resist the urge to mirror complex WordPress hooks structures in React. Use hooks for side effects, but keep components focused and presentational where possible.

Key takeaways

  • WordPress includes React via @wordpress/element—use it instead of bundling your own to keep plugins lightweight and compatible.
  • Build admin settings pages as React apps fetching and updating via REST endpoints, creating smooth user experiences without page reloads.
  • Leverage @wordpress/components for consistent, accessible UI that aligns with WordPress admin standards.
  • Use @wordpress/data for shared state across components, avoiding prop drilling and keeping complex features organized.
  • Always validate nonces, sanitize input, escape output, and respect WordPress security patterns even when working with React.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles