Building admin pages with React and @wordpress/data
The settings-page-as-SPA approach: routing, the data store, REST endpoints, and nonce handling without dragging in a second state library.
const settingsStore = createReduxStore('my-plugin/settings', {
reducer(state = { items: null, saving: false }, action) {
switch (action.type) {
case 'RECEIVE_ITEMS':
return { ...state, items: action.items };
case 'SET_SAVING':
return { ...state, saving: action.saving };
}
return state;
},
resolvers: {
*getItems() {
const items = yield apiFetch({ path: '/my-plugin/v1/settings' });
return actions.receiveItems(items);
},
},
});WordPress admin still defaults to long forms and meta boxes because that is how plugins grew up. That works until an editorial team juggles six screens to publish one piece of content, or a settings area turns into forty tabs of loosely related options. At that point you want an admin experience that behaves like software: routed views, loading states, validation that does not reload the page, and a single place where data flows in and out.
React inside wp-admin can do that. You do not need to embed a separate Next app or fight the block editor. You register a menu page, enqueue a script, mount one root, and build the UI the way you would for any internal tool, except WordPress is your auth layer, your user model, and often your database API.
One menu page, one root, client-side routing
The pattern I use most often is a top-level or submenu page whose callback prints an empty <div id="my-plugin-app"></div>, then a script that calls createRoot on that node. All navigation happens inside React. Hash routing is the least fragile when you are unsure how other plugins manipulate history, but path-based routing works if you control the slug space and enqueue rewrite-friendly assets.
WordPress handles login, capabilities, and the admin chrome. Your app handles screens. Keep that boundary clean. Do not try to replace wp-admin wholesale. Wrap your routes in a layout that uses @wordpress/components for visual consistency so users feel like they are still inside WordPress even when the interaction model is closer to a small SaaS product.
Let @wordpress/data own server state
It is tempting to fetch settings with useEffect and stash them in useState. That falls apart the moment two screens edit the same option or you need optimistic updates with rollback. @wordpress/data already solves subscription, caching, and selector memoization for WordPress entities. Extend it for your plugin’s settings and custom post types instead of importing another global store on day one.
Register a store with actions for save and refresh, selectors for derived values, and resolvers that call your REST routes. Resolvers run when a selector needs data that is not in state yet, which gives you lazy loading for free when a user opens a settings tab they rarely touch.
const settingsStore = createReduxStore( 'my-plugin/settings', {
reducer( state = { items: null, saving: false }, action ) {
switch ( action.type ) {
case 'RECEIVE_ITEMS':
return { ...state, items: action.items };
case 'SET_SAVING':
return { ...state, saving: action.saving };
}
return state;
},
actions: {
receiveItems( items ) {
return { type: 'RECEIVE_ITEMS', items };
},
setSaving( saving ) {
return { type: 'SET_SAVING', saving };
},
},
selectors: {
getItems( state ) {
return state.items;
},
isSaving( state ) {
return state.saving;
},
},
resolvers: {
*getItems() {
const items = yield apiFetch( { path: '/my-plugin/v1/settings' } );
return actions.receiveItems( items );
},
},
} );
Components use useSelect to read and useDispatch to trigger saves. Loading spinners come from resolver pending state or explicit flags you control. The mental model matches Gutenberg, which helps when you hand the project to another WordPress developer later.
REST first, admin-ajax when you must
Register REST routes with explicit namespaces and version segments. Tie permission_callback to capabilities that match what the UI shows, not just current_user_can( 'manage_options' ) because it is easy. Return structured JSON, validate with sanitize_* functions on the way in, and use schema definitions when your routes grow.
apiFetch from @wordpress/api-fetch handles nonces for admin requests when configured. Prefer it over raw fetch so you inherit WordPress cookie and nonce behavior. If you must support admin-ajax for legacy endpoints, isolate that adapter in one module so the React layer still speaks in terms of actions and selectors.
Forms, notices, and the WordPress way of feedback
Use @wordpress/components for text fields, toggles, and modals where you can. Hook into @wordpress/notices for success and error banners instead of rolling your own toast stack unless the design system demands it. When save fails, surface the REST error message and keep the user’s edits on screen. Nothing erodes trust faster than a silent failure on a long form.
For multi-step wizards, persist draft state to the server when steps complete, not only to component state. Admins leave tabs open for hours. Browser refreshes happen. If step three depends on data from step one, your store should reflect server truth after each transition.
Security and capabilities you actually check
The UI should hide actions the user cannot perform, and the REST layer must enforce the same rules. Do not rely on hiding buttons alone. Nonces stop CSRF; capabilities stop unauthorized users. If an editor role can view but not save, encode that in both the route permission callback and the disabled state of your save button with an explanation.
When exposing custom post types or meta through REST, register show_in_rest thoughtfully and use auth_callback on meta registration. React makes it easy to build powerful editors. PHP still owns authorization.
Performance in real admin sessions
Admin users keep dozens of tabs open. Avoid polling unless you need live data. Invalidate resolvers after saves instead of refetching entire settings trees on every keystroke. Split large settings objects into resources if different screens edit different slices, so one screen’s fetch does not block another.
Enqueue scripts only on your plugin’s admin pages. WordPress gives you $hook_suffix in the load callback for that. Your React bundle can be large if it includes charts or rich editors, so do not pay that cost on every wp-admin screen site-wide.
When the SPA approach is worth the overhead
Build a React admin when workflows cross multiple meta boxes, when validation depends on relationships between fields, or when users need guided steps with clear progress. Stay with classic settings API pages when you have a handful of options that rarely change. The SPA approach shines when the client treats the WordPress install like an application platform, not a blog with plugins bolted on.
Done well, the admin feels fast, errors are visible, and the next developer can trace data from button click to REST route to database without spelunking through anonymous jQuery handlers scattered across five PHP files.