Searches for WordPress React components usually come from the same job: you need a settings page for your plugin and want it to look and behave like the block editor, not like a 2010 form-table. Most tutorials stop at rendering a button. The work that’s usually left out is loading the saved option, saving it without writing your own AJAX handler, and making sure nobody can store junk or HTML in it.
This snippet gives you a complete settings page: an options screen rendered with @wordpress/components, data loaded and saved with apiFetch, and one option registered with a REST schema and a sanitize callback. You don’t need a custom REST route, admin-ajax.php or a hand-rolled nonce. WordPress already handles capability checks and nonces on the /wp/v2/settings endpoint.
The snippet
Create a small plugin folder, for example wp-content/plugins/dpc-react-settings/. Put the PHP below in dpc-react-settings.php.
<?php
/**
* Plugin Name: DPC React Settings Page
* Description: A React settings page built with @wordpress/components.
*/
defined( 'ABSPATH' ) || exit;
function dpc_react_settings_defaults(): array {
return [
'notice_text' => '',
'show_notice' => false,
'max_items' => 5,
];
}
function dpc_sanitize_react_settings( $value ): array {
$value = is_array( $value ) ? $value : [];
$defaults = dpc_react_settings_defaults();
$text = $value['notice_text'] ?? $defaults['notice_text'];
$items = $value['max_items'] ?? $defaults['max_items'];
return [
'notice_text' => is_string( $text ) ? sanitize_text_field( $text ) : '',
'show_notice' => rest_sanitize_boolean( $value['show_notice'] ?? $defaults['show_notice'] ),
'max_items' => min( 20, max( 1, absint( $items ) ) ),
];
}
function dpc_register_react_settings(): void {
register_setting(
'dpc_react_settings',
'dpc_react_settings',
[
'type' => 'object',
'default' => dpc_react_settings_defaults(),
'sanitize_callback' => 'dpc_sanitize_react_settings',
'show_in_rest' => [
'schema' => [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'notice_text' => [ 'type' => 'string' ],
'show_notice' => [ 'type' => 'boolean' ],
'max_items' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 20,
],
],
],
],
]
);
}
add_action( 'init', 'dpc_register_react_settings' );
function dpc_add_react_settings_page(): void {
add_options_page(
__( 'React Settings', 'dpc' ),
__( 'React Settings', 'dpc' ),
'manage_options',
'dpc-react-settings',
'dpc_render_react_settings_page'
);
}
add_action( 'admin_menu', 'dpc_add_react_settings_page' );
function dpc_render_react_settings_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'React Settings', 'dpc' ); ?></h1>
<div id="dpc-react-settings"></div>
</div>
<?php
}
function dpc_enqueue_react_settings_page( string $hook_suffix ): void {
if ( 'settings_page_dpc-react-settings' !== $hook_suffix ) {
return;
}
$asset_file = plugin_dir_path( __FILE__ ) . 'build/index.asset.php';
$asset = file_exists( $asset_file )
? require $asset_file
: [
'dependencies' => [ 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n' ],
'version' => '1.0.0',
];
wp_enqueue_script(
'dpc-react-settings',
plugins_url( 'build/index.js', __FILE__ ),
$asset['dependencies'],
$asset['version'],
true
);
wp_enqueue_style( 'wp-components' );
}
add_action( 'admin_enqueue_scripts', 'dpc_enqueue_react_settings_page' );
Put the React app in src/index.js in the same folder:
import apiFetch from '@wordpress/api-fetch';
import {
Button,
Card,
CardBody,
CardFooter,
Notice,
RangeControl,
Spinner,
TextControl,
ToggleControl,
} from '@wordpress/components';
import { createRoot, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
const OPTION = 'dpc_react_settings';
function SettingsPage() {
const [ settings, setSettings ] = useState( null );
const [ isSaving, setIsSaving ] = useState( false );
const [ notice, setNotice ] = useState( null );
useEffect( () => {
apiFetch( { path: '/wp/v2/settings' } )
.then( ( response ) => setSettings( response[ OPTION ] ) )
.catch( ( error ) =>
setNotice( { status: 'error', message: error.message } )
);
}, [] );
const update = ( key ) => ( value ) =>
setSettings( ( current ) => ( { ...current, [ key ]: value } ) );
const save = async () => {
setIsSaving( true );
setNotice( null );
try {
const response = await apiFetch( {
path: '/wp/v2/settings',
method: 'POST',
data: { [ OPTION ]: settings },
} );
setSettings( response[ OPTION ] );
setNotice( {
status: 'success',
message: __( 'Settings saved.', 'dpc' ),
} );
} catch ( error ) {
setNotice( { status: 'error', message: error.message } );
} finally {
setIsSaving( false );
}
};
if ( ! settings ) {
return notice ? (
<Notice status="error" isDismissible={ false }>
{ notice.message }
</Notice>
) : (
<Spinner />
);
}
return (
<Card>
<CardBody>
<div style={ { display: 'grid', gap: '16px', maxWidth: '480px' } }>
{ notice && (
<Notice
status={ notice.status }
onRemove={ () => setNotice( null ) }
>
{ notice.message }
</Notice>
) }
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Show site notice', 'dpc' ) }
checked={ settings.show_notice }
onChange={ update( 'show_notice' ) }
/>
<TextControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Notice text', 'dpc' ) }
value={ settings.notice_text }
onChange={ update( 'notice_text' ) }
/>
<RangeControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Items to show', 'dpc' ) }
min={ 1 }
max={ 20 }
value={ settings.max_items }
onChange={ ( value ) => update( 'max_items' )( value ?? 1 ) }
/>
</div>
</CardBody>
<CardFooter>
<Button
__next40pxDefaultSize
variant="primary"
isBusy={ isSaving }
disabled={ isSaving }
accessibleWhenDisabled
onClick={ save }
>
{ __( 'Save settings', 'dpc' ) }
</Button>
</CardFooter>
</Card>
);
}
const container = document.getElementById( 'dpc-react-settings' );
if ( container ) {
createRoot( container ).render( <SettingsPage /> );
}
Build it once from the plugin folder. wp-scripts compiles src/index.js to build/index.js and writes build/index.asset.php, which lists the script dependencies:
npm init -y
npm install --save-dev @wordpress/scripts
npx wp-scripts build
Activate the plugin and go to Settings → React Settings.
How it works
register_setting()withshow_in_restaddsdpc_react_settingsto the core/wp/v2/settingsendpoint. That endpoint only allows users withmanage_options. Thewp-api-fetchscript attaches the REST nonce for you on admin screens.- The REST
schemarejects requests with the wrong types, out-of-range numbers or unknown keys before anything is saved. After that,dpc_sanitize_react_settings()runs again onupdate_option(), so values saved from PHP or WP-CLI get cleaned too. dpc_enqueue_react_settings_page()loads the bundle and thewp-componentsstylesheet only on this one screen. Your other admin pages don’t load the extra JavaScript.- In the React app,
apiFetchreads the option once on mount and posts the whole object back on save. The response comes back already sanitized, so the form shows exactly what was stored. @wordpress/elementprovidescreateRootand the hooks. You use the React copy that WordPress already ships instead of bundling your own.
Customise it
- Add a field in three places: the defaults array, the schema
propertiesand the sanitize callback. Then add the matching control in JSX. If you skip the schema,additionalProperties: falsewill reject the new key. - To read the values in PHP, use
get_option( 'dpc_react_settings' ). You always get the full array, becausedefaultfills it in until the first save. - If you want a top-level menu instead of a page under Settings, swap
add_options_page()foradd_menu_page(). The hook suffix also changes totoplevel_page_dpc-react-settings, so update the check in the enqueue function. - If the components look unstyled or the spacing is off, see Load Gutenberg Component Styles on a WordPress Settings Page. For webpack tweaks, see WP Scripts Custom Config.
Gotchas
- Register the setting on
init, notadmin_init. REST requests never fireadmin_init, so a setting registered only there won’t appear in/wp/v2/settings. - If a stored value no longer matches the schema (for example after you rename a key), the endpoint returns
nullfor that setting and the page shows a spinner forever. Migrate old values or change the schema with care. - The
__next40pxDefaultSizeand__nextHasNoMarginBottomprops opt into the current component styles. Without them, WordPress 6.8+ logs deprecation warnings in the console. - The fallback dependency list in PHP is only a safety net. Ship the
build/folder with your plugin, because withoutbuild/index.jsnothing renders.
Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.
0 of 10 votes