Part 2: Creating a WordPress options page using React and Gutenberg

In part 1 of this guide, we set up a new WordPress plugin with an empty options page. In this part, we will add a basic text input field to our options page, as well as a save button to save our changes.

Step 1: Add some content to our options page

Let’s start by adding some code to our admin.js file:

JS
import { __ } from '@wordpress/i18n'; import { createRoot } from 'react-dom/client'; const OptionsPage = () => { return ( <div> <h1>{ __( 'Options Page', 'wop' ) }</h1> </div> ); }; const rootElement = document.getElementById( 'wop-admin-page' ); if ( rootElement ) { createRoot( rootElement ).render( <OptionsPage /> ); }

In the terminal, run the following command:

Terminal
npm run start

This command will keep running in your terminal window, and rebuild our files whenever it detects a change. If you go back to your WordPress admin area, you should now see something like this:

Screenshot of an empty options page

Let’s walk to this code step by step. First, we’re importing some of the dependencies we need:

JS
import { __ } from '@wordpress/i18n'; import { createRoot } from 'react-dom/client'; const OptionsPage = () => { return ( <div> <h1>{ __( 'Options Page', 'wop' ) }</h1> </div> ); }; const rootElement = document.getElementById( 'wop-admin-page' ); if ( rootElement ) { createRoot( rootElement ).render( <OptionsPage /> ); }

The first thing we’re doing is importing the __ function from the @wordpress/i18n package. This is the JavaScript equivalent of the PHP __ function, and makes any text you render translatable. Getting translations to work is beyond the scope of this guide, but it is always a good idea to make sure any strings you add to your code are translatable.

We’re also importing createRoot, which is a React function that essentially creates a blank canvas (or “root”) that we can add React components to.

A neat thing about the @wordpress/scripts package is that it automatically updates our asset file. Take a look at the build/admin.asset.php file, and you should see something like this:

PHP
<?php return array('dependencies' => array('react', 'react-dom', 'wp-i18n'), 'version' => 'e322b079c34ae8c29300');

As you can see, the dependencies we added in our JavaScript are listed here, as well as a randomly generated version number. We are using this in the wop_enqueue_scripts function from Part 1, so that we don’t need to manually update the dependencies of our script every time we add or remove something in our JavaScript file.

By using a random version number, the browser will automatically use the new version instead of the cached version.

Next, we’re creating a very simple React component, that just has a <div> and a heading. This will serve as a container for our option input that we’ll add later.

JS
import { __ } from '@wordpress/i18n'; import { createRoot } from 'react-dom/client'; const OptionsPage = () => { return ( <div> <h1>{ __( 'Options Page', 'wop' ) }</h1> </div> ); }; const rootElement = document.getElementById( 'wop-admin-page' ); if ( rootElement ) { createRoot( rootElement ).render( <OptionsPage /> ); }

Finally, we’re rendering this React component onto our options page. To do this, we use the getElementById function to find the empty <div> that we added to our options page. If it exists, we turn it into the root of our React application and render the <OptionsPage> component that we just created.

JS
import { __ } from '@wordpress/i18n'; import { createRoot } from 'react-dom/client'; const OptionsPage = () => { return ( <div> <h1>{ __( 'Options Page', 'wop' ) }</h1> </div> ); }; const rootElement = document.getElementById( 'wop-admin-page' ); if ( rootElement ) { createRoot( rootElement ).render( <OptionsPage /> ); }

Step 2: Add a text input to our options page

Obviously, an empty options page isn’t exactly useful. What we really want to do is add some inputs that are synced with our site’s options. Let’s start by adding a simple text input:

JS
import { __ } from '@wordpress/i18n'; import { createRoot } from 'react-dom/client'; import { Card, CardBody, TextControl } from '@wordpress/components'; const OptionsPage = () => { return ( <div> <h1>{ __( 'Options Page', 'wop' ) }</h1> <Card> <CardBody> <TextControl label={ __( 'Custom Field', 'wop' ) } help={ __( 'This is a custom field.', 'wop' ) } /> </CardBody> </Card> </div> ); }; const rootElement = document.getElementById( 'wop-admin-page' ); if ( rootElement ) { createRoot( rootElement ).render( <OptionsPage /> ); }

We import the <TextControl> component from the @wordpress/components package, which adds a basic text input field with a label and description. We also wrap it in the <Card> and <CardBody> components to make it look a bit nicer.

I highly recommend checking out the Gutenberg Storybook, which has interactive examples of all components in the @wordpress/components package with documentation and an overview of the props for each component.

When you refresh the page, you’ll see a working but empty text input with a label and description. You can type in it, but it won’t do anything just yet.

Step 3: Adding a new option to our plugin

To register a setting for our options page, head back to our plugin file and add the following code:

PHP
function wop_register_settings() { register_setting( 'wop', 'wop_custom_field', array( 'type' => 'string', 'description' => __( 'A custom field.', 'wop' ), 'sanitize_callback' => 'sanitize_text_field', 'show_in_rest' => true, 'default' => '', ) ); } add_action( 'admin_init', 'wop_register_settings' ); add_action( 'rest_api_init', 'wop_register_settings' );

Here, we’re using the register_setting function to register a new option named wop_custom_field. In the options array, there’s a few things to pay attention to:

  • We’re setting the type to string. This tells WordPress what type of data should be in this option.
  • We’re adding sanitize_text_field as our sanitize_callback. When saving the option, WordPress will automatically run the new value through this function before saving it to the database, to prevent saving any unwanted or dangerous content. You can also use a custom function as your callback.
  • By setting show_in_rest to true, we ensure that the option is visible in the REST API, which is what we will use in the next step.
  • Finally, setting default to an empty string ensures we’ll always have a value when retrieving the option.

We’re registering our setting in two separate hooks—admin_init and rest_api_init. This way, the setting will be registered on any admin pages as well as on any API requests.

Step 4: Adding the new option to our text input

With our new option registered in WordPress, let’s add it to our text input:

JS
import { __ } from '@wordpress/i18n'; import { useState, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { Card, CardBody, TextControl } from '@wordpress/components'; import apiFetch from '@wordpress/api-fetch'; const OptionsPage = () => { const [ customField, setCustomField ] = useState( '' ); useEffect( () => { const fetchSettings = async () => { const { wop_custom_field } = await apiFetch( { path: '/wp/v2/settings?_fields=wop_custom_field', } ); setCustomField( wop_custom_field ); }; fetchSettings().catch( ( error ) => { console.error( error ); } ); }, [] ); return ( <div> <h1>{ __( 'Options Page', 'wop' ) }</h1> <Card> <CardBody> <TextControl label={ __( 'Custom Field', 'wop' ) } help={ __( 'This is a custom field.', 'wop' ) } value={ customField } onChange={ setCustomField } /> </CardBody> </Card> </div> ); }; const rootElement = document.getElementById( 'wop-admin-page' ); if ( rootElement ) { createRoot( rootElement ).render( <OptionsPage /> ); }

There’s a lot going on here, so let’s walk through it step by step. First, we’re importing some new dependencies.

JS
import { useState, useEffect } from 'react'; import apiFetch from '@wordpress/api-fetch';

useState and useEffect are both React hooks. useState is used to manage state in a functional component, and useEffect is used to manage side effects, for example to fetch data when a component is loaded. If you want to learn more about React hooks and how they work, I highly recommend checking out the documentation.

apiFetch is a small but useful wrapper around window.fetch that’s used to make requests to the WordPress REST API.

Up until recently, @wordpress/element was used as a wrapper around React components and was the default way to import methods such as useState in your components, but WordPress is moving away from that in favor of importing React directly.

Next, we’re setting the default values of customField and setCustomField:

JS
const [ customField, setCustomField ] = useState( '' );

This sets customField to '' and creates a new function setCustomField that can be called to update the value of customField.

Right after this, we’re using useEffect and apiFetch to fetch our custom field from the database:

JS
useEffect( () => { const fetchSettings = async () => { const { wop_custom_field } = await apiFetch( { path: '/wp/v2/settings?_fields=wop_custom_field', } ); setCustomField( wop_custom_field ); }; fetchSettings(); }, [] );

useEffect takes an array of dependencies, and it will call the function each time one of the dependencies changes. By setting the dependency array to [], we’re telling our component to run the function once when the component is loaded and never again.

In our function, we create and immediately call an asynchronous function named fetchSettings that makes a call to the REST API settings endpoint to get the value of our custom option (named wop_custom_field), and updates the state with setCustomField. This means that whenever the page is loaded, the REST API is called to get the option value from the database, and customField will be set to this value.

Finally, we’re updating our <TextControl> component to use our new state:

JS
<TextControl label={ __( 'Custom Field', 'wop' ) } help={ __( 'This is a custom field.', 'wop' ) } value={ customField } onChange={ setCustomField } />

With these changes, our <TextControl> will get its value from the database. You can test it by running this WP-CLI command in your terminal:

Terminal
wp option add wop_custom_field "A test message"

After running this command and refreshing the page, the text input should say A test message. In the next step, we will add a save button to actually save any changes from the text input.

Step 5: Saving the text input

At this point, saving the text input is actually quite straightforward:

JS
import { __ } from '@wordpress/i18n'; import { useState, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { Card, CardBody, TextControl } from '@wordpress/components'; import { Button, Card, CardBody, TextControl } from '@wordpress/components'; import apiFetch from '@wordpress/api-fetch'; const OptionsPage = () => { const [ customField, setCustomField ] = useState( '' ); useEffect( () => { const fetchSettings = async () => { const { wop_custom_field } = await apiFetch( { path: '/wp/v2/settings?_fields=wop_custom_field', } ); setCustomField( wop_custom_field ); }; fetchSettings().catch( ( error ) => { console.error( error ); } ); }, [] ); const handleSubmit = async ( event ) => { event.preventDefault(); const { wop_custom_field } = await apiFetch( { path: '/wp/v2/settings', method: 'POST', data: { wop_custom_field: customField, }, } ); setCustomField( wop_custom_field ); }; return ( <div> <h1>{ __( 'Options Page', 'wop' ) }</h1> <form onSubmit={ handleSubmit }> <Card> <CardBody> <TextControl label={ __( 'Custom Field', 'wop' ) } help={ __( 'This is a custom field.', 'wop' ) } value={ customField } onChange={ setCustomField } /> <Button variant="primary" type="submit"> { __( 'Save', 'wop' ) } </Button> </CardBody> </Card> </form> </div> ); }; const rootElement = document.getElementById( 'wop-admin-page' ); if ( rootElement ) { createRoot( rootElement ).render( <OptionsPage /> ); }

First, we’re importing the <Button> component. This is a fairly standard component that can be used to render a button in various styles and run some code when the button is clicked.

JS
import { Card, CardBody, TextControl } from '@wordpress/components'; import { Button, Card, CardBody, TextControl } from '@wordpress/components';

In the body of our component, we’re wrapping everything in a <form> tag and adding our button:

JS
<form onSubmit={ handleSubmit }> <Card> <CardBody> <TextControl label={ __( 'Custom Field', 'wop' ) } help={ __( 'This is a custom field.', 'wop' ) } value={ customField } onChange={ ( value ) => { setCustomField( value ); } } /> <Button variant="primary" type="submit"> { __( 'Save', 'wop' ) } </Button> </CardBody> </Card> </form>

We’re also adding a submit handler to our form named handleSubmit, which will run when the user submits the form by pressing the save button:

JS
const handleSubmit = async ( event ) => { event.preventDefault(); const { wop_custom_field } = await apiFetch( { path: '/wp/v2/settings', method: 'POST', data: { wop_custom_field: customField, }, } ); setCustomField( wop_custom_field ); };

This code is fairly similar to the code we used to get the option from the database. First off, we’re using event.preventDefault() to prevent the default form submission logic from reloading the page.

Next, we’re using apiFetch again, this time to make a POST request to the REST API settings endpoint. We pass in the value of customField with the key wop_custom_field, which is the name of our option in register_setting.

apiFetch will return the value of the updated option in the database, and we use that to update our local state again by calling setCustomField.

And with that, we have a fully working options page! You can give it a test by updating the value and pressing the save button. Refreshing the page should show you the new value in the text input. You can also use WP-CLI to get the value of the option:

Terminal
wp option get wop_custom_field

When using this code in a real project, you will most likely want to add some extra things, such as styling, error handing, and a notification when the settings have saved successfully.

I hope that this guide serves as a great starting point to start building your own option pages in WordPress! If you have any questions or comments, please feel free to leave a comment or tweet/DM me on X! I’d love to hear your feedback.

Written by Daniel Post

Hi! I’m Daniel Post, a freelance full-stack WordPress developer from the Netherlands. This is my personal website, where I share articles and guides related to WordPress.

I am also available for hire, so if you’re looking for a developer for your next project feel free to get in touch!


12 responses to “Part 2: Creating a WordPress options page using React and Gutenberg”

  1. David Gwyer Avatar
    David Gwyer

    Thanks for this two-part series Daniel. Really well put together.

    It would be cool to see how to add a popup snack bar notifiction when you save changes. Just like you see on the block editor.

    1. Daniel Post Avatar

      Thanks David! That’s a good suggestion—I’ll add that soon, or maybe as a separate follow-up post.

    2. Edu Wass Avatar

      I agree with David. That would be a great addition.

      Congratulations Daniel! What an amazing tutorial!

      1. Daniel Post Avatar

        Thanks Edu, much appreciated! I’ll create part 3 of this tutorial to add the notification.

        1. Shahid Avatar
          Shahid

          If possible please add additional fields like like checkbox, media, select, repeater field.

          1. Daniel Post Avatar

            Those are outside the scope of this guide, but I can explore adding a media field and a repeater field in a future guide. What would you need a repeater field for?

            For checkbox and select, check out the CheckboxControl and SelectControl components: https://wordpress.github.io/gutenberg/

            They will work very similar to the TextControl component we used in the guide.

  2. Daniel Cooper Avatar

    Excellent article, Daniel!

    Are there any security considerations to keep in mind when updating settings using apiFetch?

    1. Daniel Post Avatar

      Thank you, and great question!

      apiFetch is basically a wrapper around window.fetch to create REST API requests, so the important part is to make sure your endpoints are secure.

      In this case, we’re using the core settings endpoint, which already checks if the user has the manage_options capability. We also added a sanitize_callback function to our registered setting, which sanitizes the string before we save it to the database.

      If you’re creating custom endpoints to use with apiFetch, the most important part is to add a permissions_callback function to specify what capabilities a user needs to access the endpoint.

  3. Muhammad Kamal Avatar

    Hi Daniel,

    Great post. This is the most up to date and easy to follow guide I could find.

    According to my understanding, the following hooks would be executed every time the admin is loaded.

    add_action( ‘admin_init’, ‘wop_register_settings’ );
    add_action( ‘rest_api_init’, ‘wop_register_settings’ );

    Wouldn’t it be more appropriate to add them inside the “register_activation_hook”.

    1. Daniel Post Avatar

      Hi Muhammad,

      I’m glad you enjoyed the guide!

      To answer your question: code added to `register_activation_hook` only runs once. This is useful for things such as saving an option to the database, or creating a custom database table.

      However, if we registered the settings in this hook, they would be gone the next time a page reload happens. The `register_setting` function doesn’t actually save or register anything to the database, it just tells WordPress that this setting exists. Hope that helps!

  4. Ostap Brehin Avatar

    Small correction: the createRoot import should be from @wordpress/element

    1. Daniel Post Avatar

      Gutenberg has moved away from @wordpress/element in favor of importing directly from React, so that’s what I go with as well: https://github.com/WordPress/gutenberg/issues/54074

      That being said, either will work fine. 👍🏼

Leave a Reply

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