WordPress React Settings Page With @wordpress/components

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() with show_in_rest adds dpc_react_settings to the core /wp/v2/settings endpoint. That endpoint only allows users with manage_options. The wp-api-fetch script attaches the REST nonce for you on admin screens.
  • The REST schema rejects requests with the wrong types, out-of-range numbers or unknown keys before anything is saved. After that, dpc_sanitize_react_settings() runs again on update_option(), so values saved from PHP or WP-CLI get cleaned too.
  • dpc_enqueue_react_settings_page() loads the bundle and the wp-components stylesheet only on this one screen. Your other admin pages don’t load the extra JavaScript.
  • In the React app, apiFetch reads 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/element provides createRoot and 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 properties and the sanitize callback. Then add the matching control in JSX. If you skip the schema, additionalProperties: false will reject the new key.
  • To read the values in PHP, use get_option( 'dpc_react_settings' ). You always get the full array, because default fills it in until the first save.
  • If you want a top-level menu instead of a page under Settings, swap add_options_page() for add_menu_page(). The hook suffix also changes to toplevel_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, not admin_init. REST requests never fire admin_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 null for that setting and the page shows a spinner forever. Migrate old values or change the schema with care.
  • The __next40pxDefaultSize and __nextHasNoMarginBottom props 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 without build/index.js nothing 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

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.