WordPress is in the midst of an admin redesign. During this transitional phase, the admin consists of two parts: the “classic” interface that has been around for over a decade, and the “modern” interface that comes with the Block and Site Editor.
If you’re building a plugin that needs a settings page and you want to align with the WordPress Core user experience, it’s already worth adopting the design elements and patterns that will become the new standard.
One way to do this is to use WordPress React components when building settings pages. The How to use WordPress React components for plugin pages article introduced this approach. However, thanks to the work of many in the last year or so, there is a viable alternative approach worth considering and exploring.
In this article, you’ll learn how to rebuild the same settings page that was built using individual React components, but this time using DataForm. The DataForm component provides a declarative way of creating forms and panels without the need to import individual components.
As a preview, here’s how the settings page currently looks:

And here’s the end result after the refactoring is done:

Table of Contents
Prerequisites
This article assumes you’ve already read How to use WordPress React components for plugin pages and are familiar with what was built there. If you haven’t done so, now is a good time!
This article doesn’t cover how to register an admin page, enqueue assets, or register and expose settings via the REST API. All of that was covered in the previous article and remains unchanged.
If you want to follow along, you can download or clone the Unadorned Announcement Bar plugin’s repository as a starting point and follow the setup instructions. The work continues where the previous article left off.
If you get stuck at any point, you can compare your changes with the dataform-refactor branch. Each step is committed separately.
The original SettingsPage component
The “original” settings page component consists of multiple components and a custom hook for managing state.
The code for the SettingsPage component looks like this:
const SettingsPage = () => {
const {
message,
setMessage,
display,
setDisplay,
size,
setSize,
saveSettings,
} = useSettings();
return (
<>
<SettingsTitle />
<Notices />
<Panel>
<PanelBody>
<PanelRow>
<MessageControl
value={ message }
onChange={ ( value ) => setMessage( value ) }
/>
</PanelRow>
<PanelRow>
<DisplayControl
value={ display }
onChange={ ( value ) => setDisplay( value ) }
/>
</PanelRow>
</PanelBody>
<PanelBody
title={ __( 'Appearance', 'unadorned-announcement-bar' ) }
initialOpen={ false }
>
<PanelRow>
<SizeControl
value={ size }
onChange={ ( value ) => setSize( value ) }
/>
</PanelRow>
</PanelBody>
</Panel>
<SaveButton onClick={ saveSettings } />
</>
);
};
Everything is built using components from the @wordpress/components package. Even the custom components like MessageControl are simply wrappers around components provided by WordPress:
const MessageControl = ( { value, onChange } ) => {
return (
<TextareaControl
label={ __( 'Message', 'unadorned-announcement-bar' ) }
value={ value }
onChange={ onChange }
__nextHasNoMarginBottom
/>
);
};
With DataForm, none of these individual components are needed, only the DataForm itself.
Installing the @wordpress/dataviews package
As a first step, install the @wordpress/dataviews package, as the DataForm component is part of it.
Run the following command from within the plugin’s folder:
npm install @wordpress/dataviews --save
Next, import DataForm by adding the following to the beginning of your /src/components/settings-page.jsx file:
import { DataForm } from '@wordpress/dataviews/wp';
Currently, when using @wordpress/dataviews in a WordPress context with wp-scripts (as opposed to a standalone application), you must import from @wordpress/dataviews/wp to ensure dependencies are resolved correctly.
Adding the DataForm component
Since most of the individual components are not needed in your settings-page.jsx file, you can replace the entire Panel component and its children with the DataForm component:
const SettingsPage = () => {
const {
// ...
} = useSettings();
return (
<>
<SettingsTitle />
<Notices />
<DataForm />
<SaveButton onClick={ saveSettings } />
</>
);
};
DataForm requires a few props (data, fields, form, onChange), but for now, you can provide empty values to get started. You will see in a moment what they are for.
After your changes, settings-page.jsx should look like this:
const SettingsPage = () => {
const {
// ...
} = useSettings();
const data = {};
const fields = [];
const form = {};
return (
<>
<SettingsTitle />
<Notices />
<DataForm
data={ data }
fields={ fields }
form={ form }
onChange={ () => {} }
/>
<SaveButton onClick={ saveSettings } />
</>
);
};
While you could keep the prop values inline, using variables makes the code more readable because these values will be multiple lines long.
If you refresh the settings page, you should see just the header and the Save button, as if you are starting from a blank slate.

Configuring form fields
DataForm is configuration-driven. To define a field, you need to provide at least an id, a label, and a type.
The id is a unique identifier for the field and can be any string. The label is the field’s name displayed in the UI.
The type, among other things, defines what kind of data the field holds. This includes text, integer, boolean, datetime, and others. For the full list, check the documentation, as the list grows as the package evolves.
Adding the Message field
For the message field, use the text type since it needs to be a free-form text field.
To add the configuration, update the fields variable, which will hold the configuration of all fields in settings-page.jsx:
const SettingsPage = () => {
// ...
const data = {};
const fields = [
{
id: 'message',
label: __( 'Message', 'unadorned-announcement-bar' ),
type: 'text',
},
];
const form = {};
// ..
};
This defines the field but doesn’t render it. To actually display the message field, include the field’s id in the form variable:
const SettingsPage = () => {
// ...
const data = {};
const fields = [
{
id: 'message',
// ...
}
];
const form = {
fields: [ 'message' ],
};
// ..
};
Finally, you must define an initial value by updating the data variable. For now, set the message field to an empty string.
Here’s how the updated code in the settings-page.jsx should look:
const SettingsPage = () => {
// ...
const data = {
message: '',
};
const fields = [
{
id: 'message',
// ...
}
];
const form = {
fields: [ 'message' ],
};
// ..
};
At this point, if you refresh the plugin’s settings page, you should see a text input rendered.

While it’s not a textarea like before, you’ll see how to change that shortly.
Adding the Display field
You can take the same approach for the display field. This time, use the boolean type instead of the text type because it represents an on/off state.
Update the variables in settings-page.jsx as follows:
const SettingsPage = () => {
// ...
const data = {
message: '',
display: false,
};
const fields = [
{
id: 'message',
// ...
},
{
id: 'display',
label: __( 'Display', 'unadorned-announcement-bar' ),
type: 'boolean',
},
];
const form = {
fields: [ 'message', 'display' ],
};
// ..
};
After refreshing the settings page in your browser, you should see a checkbox field rendered below the message field.

Adding the Font Size field
The remaining field is for font size. This should allow users to select from predefined options such as small, medium, and others.
There’s no dedicated “select” type in DataForm. Instead, use the text type (since the value is stored as a string) and add the optional elements property to define options. When elements is present, DataForm renders a select input instead of a text input.
Here’s how settings-page.jsx should look after the update:
const SettingsPage = () => {
// ...
const data = {
message: '',
display: false,
size: 'small',
};
const fields = [
{
id: 'message',
// ...
},
{
id: 'display',
// ...
},
{
id: 'size',
label: __( 'Font size', 'unadorned-announcement-bar' ),
type: 'text',
elements: [
{
value: 'small',
label: __( 'Small', 'unadorned-announcement-bar' ),
},
{
value: 'medium',
label: __( 'Medium', 'unadorned-announcement-bar' ),
},
{
value: 'large',
label: __( 'Large', 'unadorned-announcement-bar' ),
},
{
value: 'x-large',
label: __( 'Extra Large', 'unadorned-announcement-bar' ),
},
]
}
];
const form = {
fields: [ 'message', 'display', 'size' ],
};
// ..
};
With this, your settings page should have three fields without needing to import any specific components, all defined through configuration objects.

Customizing field UI components
When you specify a type for a field without extra configuration, each type maps to a default UI component.
As you saw, the text type renders a text input when no elements are defined. The boolean renders a checkbox.
However, you can change the default UI components by specifying the Edit property. The simplest option is to use one of the predefined UI components: textarea, toggle, radio, toggleGroup, and others.
To complete this, add the Edit property to the fields in settings-page.jsx as follows:
const SettingsPage = () => {
// ...
const fields = [
{
id: 'message',
// ...
Edit: 'textarea',
},
{
id: 'display',
// ...
Edit: 'toggle',
},
{
id: 'size',
// ...
Edit: 'toggleGroup',
},
];
// ..
};
Now, if you refresh the settings page, you should see a textarea, a toggle, and a toggle group for the fields.

If you need more than the built-in UI components, you can use your own custom components instead. This allows you to drive your settings page using configuration without being limited by what UI components are available.
Refer to the documentation to see how to do that and what built-in components are available today. New components are added over time!
Configuring the form layout
At this point, you have the same controls as before, but the layout is different. The fields aren’t grouped under panels, and the font size control isn’t hidden by default.
DataForm not only allows you to define fields using a configuration, but also lets you configure the overall layout used to display them.
Grouping fields into sections
To recreate the previous layout, group the fields into different sections. To achieve that, update the form variable in settings-page.jsx as follows:
const SettingsPage = () => {
// ...
const form = {
fields: [
{
id: 'bar',
label: __( 'Bar', 'unadorned-announcement-bar' ),
children: [ 'message', 'display' ],
},
{
id: 'appearance',
label: __( 'Appearance', 'unadorned-announcement-bar' ),
children: [ 'size' ],
},
],
};
// ..
};
The configuration requires an id and a label, just like a field definition. However, instead of defining a type, you assign existing fields as children.
If you refresh the settings, you can see the fields grouped into two sections.

Switching the layout type
By default, DataForm uses the regular layout, which is the implicit one, which lays out the fields one under the other in separate rows. However, there are other layouts available: panel, card, and row.
The card layout gets you closest to the “original design”, but try the other layouts as well to see how they look!
To switch to the card layout, update the configuration of the form in the settings-page.jsx as follows:
const SettingsPage = () => {
// ...
const form = {
fields: [
{
id: 'bar',
// ...
layout: { type: 'card' },
},
{
id: 'appearance',
// ...
layout: { type: 'card' },
},
],
};
// ..
};
If it works for you, you can even mix and match layout types for each group separately, allowing you to create various combinations.

Adjusting layout options
Depending on the layout, you can specify additional settings.
For example, for the card layout, you can control whether a header is displayed or if the card itself is expanded or collapsed by default. To see all the options for each type, check the documentation.
To remove the header from the first group and make the second group collapsed by default, modify the layout configuration as follows:
const SettingsPage = () => {
// ...
const form = {
fields: [
{
id: 'bar',
// ...
layout: { type: 'card', withHeader: false },
},
{
id: 'appearance',
// ...
layout: { type: 'card', isOpened: false },
},
],
};
// ..
};
With this change, if you refresh the settings page, the UI matches very closely to what existed before.

The spacing and width of some elements still need minor tweaks, but these can be addressed with the use of VStack and custom CSS.
With just a few lines, you can polish these details to achieve the final result:

Since writing CSS is outside the overall scope of this article, refer to the GitHub repository to see how it was implemented.
Connecting DataForm to the settings state
The settings page is rendered, however it’s still using hardcoded data that’s passed to the DataForm, and the onChange is not yet implemented:
const SettingsPage = () => {
const {
// ...
} = useSettings();
const data = {
message: '',
display: false,
size: 'small',
};
// ...
return (
<>
<SettingsTitle />
<Notices />
<DataForm
data={ data }
fields={ fields }
form={ form }
onChange={ () => {} }
/>
<SaveButton onClick={ saveSettings } />
</>
);
};
The final step is to connect the DataForm with the useSettings custom hook, which handles state management.
Refactoring the useSettings hook
Currently, the state of fields is stored separately using three useState calls, but the actual settings are fetched and saved as an object under the unadorned_announcement_bar key.
As a reference, here’s the code for the current custom hook in /src/hooks/use-settings.js:
const useSettings = () => {
const [ message, setMessage ] = useState();
const [ display, setDisplay ] = useState();
const [ size, setSize ] = useState();
const { createSuccessNotice } = useDispatch( noticesStore );
useEffect( () => {
apiFetch( { path: '/wp/v2/settings' } ).then( ( wpSettings ) => {
setMessage( wpSettings.unadorned_announcement_bar.message );
setDisplay( wpSettings.unadorned_announcement_bar.display );
setSize( wpSettings.unadorned_announcement_bar.size );
} );
}, [] );
const saveSettings = () => {
apiFetch( {
path: '/wp/v2/settings',
method: 'POST',
data: {
unadorned_announcement_bar: {
message,
display,
size,
},
},
} ).then( () => {
createSuccessNotice(
__( 'Settings saved.', 'unadorned-announcement-bar' )
);
} );
};
return {
message,
setMessage,
display,
setDisplay,
size,
setSize,
saveSettings,
};
};
The setting is registered on the server side and has some default values and its schema defined. For reference, here’s how it was registered:
function unadorned_announcement_bar_settings() {
$default = array(
'message' => __( 'Hello, World!', 'unadorned-announcement-bar' ),
'display' => true,
'size' => 'medium',
);
$schema = array(
'type' => 'object',
'properties' => array(
'message' => array(
'type' => 'string',
),
'display' => array(
'type' => 'boolean',
),
'size' => array(
'type' => 'string',
'enum' => array(
'small',
'medium',
'large',
'x-large',
),
),
),
);
register_setting(
'options',
'unadorned_announcement_bar',
array(
'type' => 'object',
'default' => $default,
'show_in_rest' => array(
'schema' => $schema,
),
)
);
}
add_action( 'init', 'unadorned_announcement_bar_settings' );
Since there are no longer individual components for the fields and the DataForm uses an object for the data prop, it’s more straightforward to store the field state as an object too. This will also match what’s actually stored in the database.
First replace the three separate state variables in use-settings.js:
const useSettings = () => {
const [ settings, setSettings ] = useState( {
message: '',
display: false,
size: 'small',
} );
// ...
return [ settings, setSettings, saveSettings ];
};
Since the data stored in the options table matches the state structure exactly, you can simplify both reading and saving.
For the initial data loading, update the success handler inside the useEffect hook:
const useSettings = () => {
// ...
useEffect( () => {
apiFetch( { path: '/wp/v2/settings' } ).then( ( wpSettings ) => {
setSettings( wpSettings.unadorned_announcement_bar )
} );
}, [] );
// ...
};
For saving the settings, update the saveSettings as follows:
const useSettings = () => {
// ...
const saveSettings = () => {
apiFetch( {
path: '/wp/v2/settings',
method: 'POST',
data: {
unadorned_announcement_bar: settings
},
} ).then( () => {
// ...
} );
};
// ...
};
Putting it all together, here’s how the useSettings should look after the previous updates:
const useSettings = () => {
const [settings, setSettings] = useState({
message: "",
display: false,
size: "small",
});
const { createSuccessNotice } = useDispatch(noticesStore);
useEffect(() => {
apiFetch({ path: "/wp/v2/settings" }).then((wpSettings) => {
setSettings(wpSettings.unadorned_announcement_bar);
});
}, []);
const saveSettings = () => {
apiFetch({
path: "/wp/v2/settings",
method: "POST",
data: {
unadorned_announcement_bar: settings,
},
}).then(() => {
createSuccessNotice(
__("Settings saved.", "unadorned-announcement-bar"),
);
});
};
return {
settings,
setSettings,
saveSettings,
};
};
Connecting the useSettings with DataForm
Now that useSettings returns the state as a single object, update the SettingsPage component.
Change how you destructure the hook, remove the data variable, and pass settings to DataForm.
After the changes, the code in /src/components/settings-page.jsx should look like this:
const SettingsPage = () => {
const [ settings, setSettings, saveSettings ] = useSettings();
const fields = [
// ...
]
const form = {
// ..
}
return (
<>
<SettingsTitle />
<Notices />
<DataForm
data={ settings }
fields={ fields }
form={ form }
onChange={ () => {} }
/>
<SaveButton onClick={ saveSettings } />
</>
);
};
Implementing the onChange callback
The last part is updating the internal state (settings) whenever a field is updated. For this, you have to implement the onChange callback.
The onChange callback only receives the changed data (the “edits”), not the complete state. For example, when you update the message field, onChange will receive { message: 'updated value' }.
This means you can’t simply pass the changes to setSettings, as it would replace the entire state with just the changed field. Instead, you have to merge the current state with the changes.
Here’s how the onChange callback should look after the update:
const SettingsPage = () => {
// ...
return (
<>
<SettingsTitle />
<Notices />
<DataForm
data={ settings }
fields={ fields }
form={ form }
onChange={ ( edits ) =>
setSettings( ( current ) => ( {
...current,
...edits,
} ) )
}
/>
<SaveButton onClick={ saveSettings } />
</>
);
};
Essentially, the spread operator (...) first copies all properties from the current state, then overwrites any properties that exist in edits. This ensures unchanged fields keep their values while changed fields get updated.
And with this, you successfully refactored the settings page using the DataForm component and configuration driven approach!
Wrapping up
Try it yourself
You can check the final result in the GitHub repository to see the entire code together, or you can compare the two solutions.
If you want to try it out without downloading and installing it, you can do that using WordPress Playground.
Where to go from here
If you’re excited about DataForm, there are some other areas you can explore on your own.
For example, you can look into the validation feature that allows you to do client-side validation and define which fields are required, among other things.
You can also look into how to load the elements asynchronously instead of having them hardcoded. This would allow you to dynamically load the options of select fields from the database.
It’s worth checking the Storybook for the DataForm component, as it has interactive demos for all the layout types in various combinations.
Lastly, to get up to speed with the latest changes the DataViews, DataForm, et al. in WordPress 6.9 is a good starting point. For specific details, check the package documentation page.
Props to @psykro and @areziaal for their reviews, and to @juanmaguitar for ongoing education about this topic.
Leave a Reply