How to create an options screen in React with @wordpress/data (JS) in WordPress

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:

  1. Register an admin menu page with add_menu_page.
  2. Register a REST endpoint to GET and POST options (with proper capabilities and nonce).
  3. 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:

  1. On mount, dispatches fetchOptions() and then commits receiveOptions() when data arrives.
  2. Displays fields (TextControl, ToggleControl) bound to the store.
  3. On Save button click, dispatches saveOptions() and updates the store upon success (receiving the saved options).
  4. 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

  1. Install dependencies: npm install.
  2. Run npm run build to create build/index.js and index.asset.php (this is how @wordpress/scripts outputs dependencies and version).
  3. Activate the plugin in WP admin.
  4. 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

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 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *