Contents
Overview
This tutorial shows, step by step, how to create a WordPress admin options screen built in React that uses the official @wordpress/data package for state management. It covers the full flow: scaffolding, registering the admin page, enqueueing the JavaScript bundle, registering a data store with actions/controls/reducers/selectors, implementing a REST route to persist options, and the React UI that reads/writes via the store (including optimistic updates, error handling, and best practices).
Prerequisites
- WordPress 5.8 (packages @wordpress/ are available in core JavaScript build).
- Node.js and npm/yarn for building the JavaScript bundle.
- Familiarity with basic React/JSX and WordPress PHP plugin bootstrap.
- Familiarity with @wordpress/scripts (recommended) to handle build/babel config.
High-level architecture
- PHP plugin registers admin menu page and REST endpoint to get/save options.
- PHP registers and enqueues a JavaScript bundle that contains the React UI and the store.
- JavaScript registers a @wordpress/data store named e.g. my-plugin/options.
- React UI components use useSelect and useDispatch (from @wordpress/data) to read and update options.
- Actions that need server interaction are mapped to controls which call @wordpress/api-fetch to the REST route.
Project file structure (suggested)
- my-plugin/
- my-plugin.php (main plugin bootstrap)
- src/
- index.js (entry — registers store, mounts React UI)
- store.js (store registration — actions/reducers/controls/selectors)
- components/
- OptionsScreen.js (React UI)
- package.json (build scripts)
- webpack (handled by @wordpress/scripts)
1) PHP: Plugin bootstrap, admin page, REST routes and script enqueue
Create the plugin main file. It will:
- Register an admin menu page with add_menu_page.
- Register a REST endpoint to GET and POST options (with proper capabilities and nonce).
- Register and enqueue the built JS bundle and expose REST root nonce to apiFetch.
Example plugin PHP (place in my-plugin/my-plugin.php):
lt?php / Plugin Name: My Plugin Options (React @wordpress/data) Description: Demo: admin options screen using React and @wordpress/data. Version: 1.0 Author: Example / defined( ABSPATH ) exit class My_Plugin_Options { const OPTION_KEY = my_plugin_options public function __construct() { add_action( admin_menu, array( this, register_admin_menu ) ) add_action( admin_enqueue_scripts, array( this, enqueue_admin_scripts ) ) add_action( rest_api_init, array( this, register_rest_routes ) ) } public function register_admin_menu() { add_menu_page( My Plugin Options, My Options, manage_options, my-plugin-options, array( this, render_admin_page ), dashicons-admin-generic, 80 ) } public function render_admin_page() { // The root element for the React app. echo ltdiv id=my-plugin-options-rootgtlt/divgt } public function enqueue_admin_scripts( hook ) { // Only load on our plugin page. if ( toplevel_page_my-plugin-options !== hook ) { return } asset_file = plugin_dir_path( __FILE__ ) . build/index.asset.php if ( file_exists( asset_file ) ) { assets = include asset_file deps = isset( assets[dependencies] ) ? assets[dependencies] : array() ver = isset( assets[version] ) ? assets[version] : filemtime( plugin_dir_path( __FILE__ ) . build/index.js ) } else { // Fallback deps = array( wp-element, wp-components, wp-data, wp-api-fetch ) ver = false } wp_register_script( my-plugin-options-script, plugins_url( build/index.js, __FILE__ ), deps, ver, true ) // Provide REST root and nonce for apiFetch wp_localize_script( my-plugin-options-script, wpApiSettings, array( root => esc_url_raw( rest_url() ), nonce => wp_create_nonce( wp_rest ), ) ) wp_enqueue_script( my-plugin-options-script ) // Optional: styles wp_enqueue_style( my-plugin-options-style, plugins_url( build/index.css, __FILE__ ), array(), ver ) } public function register_rest_routes() { register_rest_route( my-plugin/v1, /options, array( array( methods =gt GET, callback =gt array( this, get_options ), permission_callback =gt function() { return current_user_can( manage_options ) }, ), array( methods =gt POST, callback =gt array( this, save_options ), permission_callback =gt function() { return current_user_can( manage_options ) }, args => array( options => array( required => true, type => array, ), ), ), ) ) } public function get_options( WP_REST_Request request ) { options = get_option( self::OPTION_KEY, array() ) return rest_ensure_response( options ) } public function save_options( WP_REST_Request request ) { params = request->get_json_params() if ( ! isset( params[options] ) ! is_array( params[options] ) ) { return new WP_Error( invalid_data, Invalid options payload, array( status =gt 400 ) ) } // Validate sanitize here: options = array_map( sanitize_text_field, params[options] ) update_option( self::OPTION_KEY, options ) return rest_ensure_response( array( success =gt true, options =gt options ) ) } } new My_Plugin_Options()
Notes on security and capability checks
- REST routes use permission_callback to enforce manage_options capability.
- Sanitize all incoming option values. The demo uses sanitize_text_field adapt to your option types.
- wp_localize_script sets wpApiSettings so @wordpress/api-fetch will include nonce in requests.
2) Build tooling (package.json) and @wordpress/scripts
Use @wordpress/scripts for a simple build. It provides webpack, babel and JSX support preconfigured for WordPress packages.
{ name: my-plugin-options, version: 1.0.0, private: true, scripts: { start: wp-scripts start, build: wp-scripts build }, devDependencies: { @wordpress/scripts: ^25.0.0 }, dependencies: { @wordpress/api-fetch: ^6.0.0, @wordpress/data: ^7.0.0, @wordpress/element: ^4.0.0, @wordpress/components: ^7.0.0 } }
3) JavaScript: store registration (store.js)
We register a custom data store that encapsulates fetch/save logic, state, and selectors. The store will:
- Keep current options in state.
- Expose actions: fetchOptions() and saveOptions(payload).
- Map those actions to controls that call apiFetch.
- Provide selectors for components: getOptions(), isSaving(), isLoading(), getError().
Implementation (src/store.js):
import { registerStore } from @wordpress/data import apiFetch from @wordpress/api-fetch // Store name const STORE_NAME = my-plugin/options // Initial state const DEFAULT_STATE = { options: {}, isSaving: false, isLoading: false, error: null, } // Action constants const ACTIONS = { RECEIVE_OPTIONS: RECEIVE_OPTIONS, FETCH_STARTED: FETCH_STARTED, FETCH_FINISHED: FETCH_FINISHED, SAVE_STARTED: SAVE_STARTED, SAVE_FINISHED: SAVE_FINISHED, SAVE_ERROR: SAVE_ERROR, SET_ERROR: SET_ERROR, } // Reducer function reducer( state = DEFAULT_STATE, action ) { switch ( action.type ) { case ACTIONS.RECEIVE_OPTIONS: return { ...state, options: action.options {}, isLoading: false, error: null, } case ACTIONS.FETCH_STARTED: return { ...state, isLoading: true, error: null } case ACTIONS.FETCH_FINISHED: return { ...state, isLoading: false } case ACTIONS.SAVE_STARTED: return { ...state, isSaving: true, error: null } case ACTIONS.SAVE_FINISHED: return { ...state, isSaving: false, options: action.options {} } case ACTIONS.SAVE_ERROR: return { ...state, isSaving: false, error: action.error } case ACTIONS.SET_ERROR: return { ...state, error: action.error } default: return state } } // Actions const actions = { receiveOptions( options ) { return { type: ACTIONS.RECEIVE_OPTIONS, options } }, fetchOptions() { // This triggers the control named FETCH_OPTIONS return { type: FETCH_OPTIONS } }, saveOptions( options ) { // This triggers the control named SAVE_OPTIONS return { type: SAVE_OPTIONS, options } }, } // Controls - map action types to side effects (apiFetch) const controls = { FETCH_OPTIONS() { // GET /my-plugin/v1/options return apiFetch( { path: /my-plugin/v1/options } ) }, SAVE_OPTIONS( action ) { // POST /my-plugin/v1/options return apiFetch( { path: /my-plugin/v1/options, method: POST, data: { options: action.options }, } ) }, } // Resolvers (optional). Well use a simple resolver for getOptions to auto-fetch when selector is used. const resolvers = { getOptions() { // Dispatch fetch started const { dispatch } = yield // resolver environment provides yield usage pattern in docs but simpler: call action that triggers control. // For simplicity we do nothing here: components will call dispatch(my-plugin/options).fetchOptions() }, } // Selectors const selectors = { getOptions( state ) { return state.options }, isSaving( state ) { return state.isSaving }, isLoading( state ) { return state.isLoading }, getError( state ) { return state.error }, } // Register the store and add side-effect handling through extra reducer like pattern in middleware below // Because registerStore supports controls and resolvers, we register and then also use the dispatch wrapper in components to manage lifecycle notifications. registerStore( STORE_NAME, { reducer, actions, selectors, controls, resolvers, } )
Important notes about controls, lifecycle and UI notifications
- Controls return a Promise (apiFetch returns a promise). When a component calls dispatch(my-plugin/options).fetchOptions(), it receives that promise and can then dispatch other actions (e.g., receiveOptions) based on the response.
- To keep the reducer in sync with lifecycle (isLoading/isSaving), manage those lifecycle actions from your UI or from the action chain where the component dispatches lifecycle actions before/after the control call. Alternatively, you can implement custom middleware or more elaborate control handlers to dispatch lifecycle actions automatically. Below we show the UI handling lifecycle for clarity.
4) React UI: OptionsScreen component
Create an options screen component that:
- On mount, dispatches fetchOptions() and then commits receiveOptions() when data arrives.
- Displays fields (TextControl, ToggleControl) bound to the store.
- On Save button click, dispatches saveOptions() and updates the store upon success (receiving the saved options).
- Shows loading and error states.
Implementation (src/components/OptionsScreen.js):
import { useEffect, useState } from @wordpress/element import { useDispatch, useSelect } from @wordpress/data import { TextControl, ToggleControl, Button, Notice, Spinner, Panel, PanelBody } from @wordpress/components export default function OptionsScreen() { const { fetchOptions, saveOptions, receiveOptions } = useDispatch( my-plugin/options ) const options = useSelect( ( select ) => select( my-plugin/options ).getOptions() {}, [] ) const isSaving = useSelect( ( select ) => select( my-plugin/options ).isSaving(), [] ) const isLoading = useSelect( ( select ) => select( my-plugin/options ).isLoading(), [] ) const error = useSelect( ( select ) => select( my-plugin/options ).getError(), [] ) // Local UI state for form editing const [ localOptions, setLocalOptions ] = useState( options ) const [ notice, setNotice ] = useState( null ) // Sync store options to local state when options change (e.g., after fetch or save) useEffect( () => { setLocalOptions( { ...options } ) }, [ options ] ) // On mount: fetch options useEffect( () => { // Indicate loading: dispatch a local reducer action or manage local isLoading state. // For simplicity call fetchOptions and then receiveOptions upon success. fetchOptions() .then( ( data ) => { // data is expected to be the options object // dispatch receiveOptions to update store state receiveOptions( data ) } ) .catch( ( err ) => { // dispatch a set error action or use component state for display // Here well set a notice setNotice( { status: error, message: err.message Failed to load options } ) } ) }, [] ) function onFieldChange( key, value ) { setLocalOptions( ( prev ) => ( { ...prev, [ key ]: value } ) ) } function onSave() { // Optimistic UI: show saving note setNotice( { status: info, message: Saving... } ) // We call the saveOptions action it triggers control SAVE_OPTIONS (apiFetch). // The store itself currently does not flip isSaving for that we used component UI state via notice. saveOptions( localOptions ) .then( ( response ) => { // Server returns e.g. { success: true, options: {...} } if ( response response.options ) { receiveOptions( response.options ) setNotice( { status: success, message: Options saved. } ) } else { setNotice( { status: success, message: Saved (no returned payload). } ) } } ) .catch( ( err ) => { setNotice( { status: error, message: err.message Save failed. } ) } ) } return ( ltdiv style={{ padding: 24, maxWidth: 720 }}gt lth3gtMy Plugin Optionslt/h3gt { notice ltNotice status={ notice.status } isDismissible={ true } onRemove={() =gt setNotice(null) }gt{ notice.message }lt/Noticegt } { isLoading ? ltSpinner /gt : ( ltPanel variant=defaultgt ltPanelBody title=Main Options initialOpen={ true }gt ltTextControl label=Site Value value={ localOptions.site_value } onChange={ ( val ) =gt onFieldChange( site_value, val ) } /gt ltToggleControl label=Enable feature checked={ !! localOptions.enable_feature } onChange={ ( val ) =gt onFieldChange( enable_feature, val ) } /gt ltdiv style={{ marginTop: 16 }}gt ltButton isPrimary onClick={ onSave } isBusy={ isSaving }gtSave Optionslt/Buttongt lt/divgt lt/PanelBodygt lt/Panelgt ) } lt/divgt ) }
5) Entry point: register store and mount React app (index.js)
The entry point imports the store registration (so it runs), the component and then renders the UI into the root element placed by PHP.
import ./store // registers the my-plugin/options store import OptionsScreen from ./components/OptionsScreen import { render } from @wordpress/element document.addEventListener( DOMContentLoaded, function () { const root = document.getElementById( my-plugin-options-root ) if ( root ) { render( ltOptionsScreen /gt, root ) } } )
6) Styles (optional)
You can include a small CSS file for spacing. Example (src/style.css):
/ Simple spacing / .my-plugin-options .form-row { margin-bottom: 1rem }
7) Build and deploy
- Install dependencies: npm install.
- Run npm run build to create build/index.js and index.asset.php (this is how @wordpress/scripts outputs dependencies and version).
- Activate the plugin in WP admin.
- Visit the admin menu My Options and the React UI will mount.
8) Walkthrough of key parts and reasoning
Why use @wordpress/data?
@wordpress/data offers a central, predictable state store for complex UI. It integrates with other WordPress packages and supports resolvers and controls for asynchronous fetching. Using a store lets components stay simple (read via selectors, update via actions/dispatch) and centralizes server interaction logic.
Controls vs resolvers
- Controls map action types to asynchronous implementations (apiFetch) and return Promises. When dispatching an action whose type matches a control, dispatch resolves to the controls result.
- Resolvers are an alternative for automatically loading data when a selector is used. In many simple cases, explicit fetch calls in useEffect are clearer.
Lifecycle flags (isLoading/isSaving)
There are multiple places to manage lifecycle flags:
- Keep them in the store and dispatch additional lifecycle actions before/after control calls. This centralizes state for all components.
- Alternatively, let the component show local loading state using Promise resolution and still update store data on success. This is simpler for small UIs.
apiFetch and nonce
By localizing wpApiSettings with root and nonce, @wordpress/api-fetch will automatically attach the X-WP-Nonce header for requests to the REST API. That avoids manual header manipulation.
9) Advanced topics and best practices
Validation and sanitization on the server
- Always sanitize input in the REST callback. Use appropriate sanitizers for number, URL, boolean, arrays, etc.
- For complex option types, validate shape and return clear error responses with proper HTTP status codes (400, 422, 500).
Optimistic UI updates
- For snappy UI, optimistically update the store before the server responds, then reconcile on success/failure. Implement rollback on failure.
- Keep user feedback (notice with undo or retry) when a save fails.
Unit testing the store
- Export the reducer and write unit tests for reducer and selectors.
- Mock apiFetch to test control logic and that actions return Promises resolving to expected payloads.
Extending the store
- For multiple option groups, still use a single store with nested keys (options.groupA, options.groupB) or separate stores if complexity grows.
- Consider normalizing large datasets for performance.
10) Complete minimal working example
Below is a compact, ready-to-use summary for reference. It includes the minimal parts you need to get a functioning admin page that fetches and saves options.
PHP (plugin main) — summarized earlier ensure this file is present and plugin activated.
JS: store UI mount are shown above. Key patterns:
- registerStore(my-plugin/options, …)
- controls use apiFetch to call /my-plugin/v1/options.
- Component dispatches fetchOptions() and saveOptions() and uses receiveOptions() to put server results into the store.
11) Troubleshooting
- If apiFetch returns 403: ensure the REST nonce was localized as wpApiSettings and the permission_callback in register_rest_route returns true for current user.
- If JavaScript fails to find the root element: confirm render target id matches the one emitted by PHP.
- If dependencies missing at runtime: ensure index.asset.php is present and used when registering the script (wp_register_script) so WP loads core script dependencies (wp-data, wp-element, etc.).
12) Useful links
- @wordpress/data documentation
- WordPress REST API Handbook
- @wordpress/api-fetch
- @wordpress/components (UI primitives)
Final notes
This tutorial provides a robust pattern to build React-driven admin UIs backed by a formal data store. Adapt the shape of the store, validation rules, schema and UI components as your plugin requires. Follow WordPress security practices: capability checks, nonces, and proper sanitization before persisting data.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |