When building a React-based WordPress admin plugin using client-side routing (like React Router), you'll quickly notice a UX problem: the WordPress admin menu doesn't stay in sync with your React routes. As users navigate your React app, the wrong menu item remains highlighted, confusing the experience.
This guide shows you how to solve this by dynamically syncing the WordPress admin sidebar menu's active state with your React Router navigation.
Table of Contents
- The Problem
- How It Works
- Prerequisites
- Step 1: Register WordPress Admin Menus
- Step 2: Define React Routes
- Step 3: Create the Sync Hook
- Step 4: Use the Hook
- Complete Code Examples
- How It Works Under the Hood
- Troubleshooting
The Problem
In a typical React-based WordPress plugin:
- You register multiple WordPress admin submenus (e.g., Dashboard, Settings, Tables)
- Each submenu renders the same container div where your React app mounts
- React Router handles navigation internally using hash-based routing (
#/settings,#/tables) - WordPress doesn't know about these route changes, so the active menu item never updates
The result: clicking "Settings" in your React app leaves "Dashboard" highlighted in the WordPress sidebar.
How It Works
Our solution:
- Monitor React Router's location changes using a custom hook
- Map each React route to its corresponding WordPress menu slug
-
Update the browser's URL query parameter (
?page=plugin-slug) without page reload -
Manipulate the DOM to toggle WordPress's
.currentclass on menu items
Prerequisites
Before implementing this solution, ensure you have:
- WordPress 5.0+
- React 18+ in your plugin
-
React Router v6 (using
HashRouter) - Basic knowledge of WordPress admin menu registration
- TypeScript (optional, examples use TypeScript)
Required Dependencies
# Bun
bun add react-router-dom
# npm
npm install react-router-dom
Step 1: Register WordPress Admin Menus
First, register your admin menus in PHP. Each submenu must have a unique slug that we'll reference later. (Note: our code is class-based or in OOP structure).
<?php
namespace YourPlugin\Admin;
class Admin {
/** Plugin menu slug constant */
const MENU_SLUG = 'your-plugin';
const CAPABILITY = 'manage_options';
const TEXT_DOMAIN = 'your-plugin';
/**
* Register admin menu pages.
* Hook this to 'admin_menu' action.
*/
public function register_menu(): void {
// Top-level menu
add_menu_page(
__('Your Plugin', self::TEXT_DOMAIN),
__('Your Plugin', self::TEXT_DOMAIN),
self::CAPABILITY,
self::MENU_SLUG,
[$this, 'render_app'],
'dashicons-admin-generic',
58
);
// Dashboard submenu (same slug as parent hides duplicate)
add_submenu_page(
self::MENU_SLUG,
__('Dashboard', self::TEXT_DOMAIN),
__('Dashboard', self::TEXT_DOMAIN),
self::CAPABILITY,
self::MENU_SLUG, // Same as parent = "Dashboard"
[$this, 'render_app']
);
// Settings submenu
add_submenu_page(
self::MENU_SLUG,
__('Settings', self::TEXT_DOMAIN),
__('Settings', self::TEXT_DOMAIN),
self::CAPABILITY,
self::MENU_SLUG . '-settings', // Unique slug
[$this, 'render_app']
);
// Tables submenu
add_submenu_page(
self::MENU_SLUG,
__('Tables', self::TEXT_DOMAIN),
__('Tables', self::TEXT_DOMAIN),
self::CAPABILITY,
self::MENU_SLUG . '-tables',
[$this, 'render_app']
);
}
/**
* Render the React app container.
* All submenus point here; React Router handles routing.
*/
public function render_app(): void {
echo '<div id="your-plugin-root"></div>';
}
}
Key Points:
- All submenus call the same callback (
render_app) — React handles routing - Each submenu has a unique slug pattern:
plugin-slug,plugin-slug-settings, etc. - These slugs appear in the URL:
?page=your-plugin-settings
Step 2: Define React Routes
Set up your React Router with centralized path constants:
// src/utils/routes.ts
import { ComponentType } from 'react';
import Dashboard from '@/pages/Dashboard';
import Settings from '@/pages/Settings';
import Tables from '@/pages/Tables';
import EditItem from '@/pages/EditItem';
/**
* Centralized path constants for routing.
* Makes mapping to WordPress slugs easier.
*/
export const PATHS = {
DASHBOARD: '/',
SETTINGS: '/settings',
TABLES: '/tables',
EDIT: '/edit/:id',
} as const;
export interface RouteConfig {
path: string;
element: ComponentType;
label?: string;
}
export const routes: RouteConfig[] = [
{ path: PATHS.DASHBOARD, element: Dashboard },
{ path: PATHS.SETTINGS, element: Settings },
{ path: PATHS.TABLES, element: Tables },
{ path: PATHS.EDIT, element: EditItem },
];
Step 3: Create the Sync Hook
This is the core of our solution - a custom hook that syncs WordPress menu state with React Router:
// src/hooks/useWpMenuSync.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { PATHS } from '@/utils/routes';
/**
* Custom hook to sync WordPress admin menu active state with React Router.
*
* Solves the UX issue where WordPress sidebar menu items don't highlight
* correctly when navigating within a React SPA that uses client-side routing.
*
* How it works:
* 1. Listens to React Router location changes
* 2. Maps the current React route to the corresponding WP admin menu slug
* 3. Updates the browser URL's ?page= query parameter (without reload)
* 4. Manipulates the DOM to toggle WordPress's .current class on menu items
*/
export const useWpMenuSync = (): void => {
const location = useLocation();
useEffect(() => {
const path = location.pathname;
// Default to main plugin slug (Dashboard)
let slug = 'your-plugin';
// Map React routes to WordPress menu slugs
if (path === PATHS.DASHBOARD) {
slug = 'your-plugin';
} else if (path === PATHS.SETTINGS) {
slug = 'your-plugin-settings';
} else if (path === PATHS.TABLES || path.startsWith('/edit')) {
// Group related routes under same menu item
slug = 'your-plugin-tables';
}
// --- 1. Update URL query parameter without page reload ---
const currentUrl = new URL(window.location.href);
const searchParams = currentUrl.searchParams;
// Only update if the slug has changed to avoid redundant history entries
if (searchParams.get('page') !== slug) {
searchParams.set('page', slug);
// Use replaceState to avoid cluttering browser history
// Preserve the hash for React Router's HashRouter
const newUrl = `${currentUrl.pathname}?${searchParams.toString()}${window.location.hash}`;
window.history.replaceState(null, '', newUrl);
}
// --- 2. Update WordPress Admin Menu DOM ---
// Find the menu link matching our target slug
const menuLink = document.querySelector<HTMLAnchorElement>(
`ul.wp-submenu a[href*="page=${slug}"]`
);
if (menuLink) {
// Get the parent submenu container
const submenu = menuLink.closest('ul.wp-submenu');
if (submenu) {
// Remove 'current' class from all sibling items
submenu.querySelectorAll('li').forEach((li) => {
li.classList.remove('current');
});
submenu.querySelectorAll('a').forEach((a) => {
a.classList.remove('current');
});
// Add 'current' class to the active item
const parentLi = menuLink.closest('li');
if (parentLi) {
parentLi.classList.add('current');
}
menuLink.classList.add('current');
}
}
}, [location]);
};
Step 4: Use the Hook
Call the hook in your app's root component (or anywhere within the Router context):
// src/App.tsx
import { HashRouter, Routes, Route } from 'react-router-dom';
import { routes } from '@/utils/routes';
import { useWpMenuSync } from '@/hooks/useWpMenuSync';
import Layout from '@/components/Layout';
/**
* Component that activates the menu sync.
* Must be rendered inside Router context.
*/
const MenuSyncProvider = ({ children }: { children: React.ReactNode }) => {
useWpMenuSync();
return <>{children}</>;
};
const App = () => {
return (
<HashRouter>
<MenuSyncProvider>
<Layout>
<Routes>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={<route.element />}
/>
))}
</Routes>
</Layout>
</MenuSyncProvider>
</HashRouter>
);
};
export default App;
Complete Code Examples
File Structure
your-plugin/
├── app/
│ └── Admin/
│ └── Admin.php # WordPress menu registration
├── src/
│ ├── App.tsx # Main React app
│ ├── hooks/
│ │ └── useWpMenuSync.ts
│ └── utils/
│ └── routes.ts
Route-to-Slug Mapping Table
| React Route | WordPress Menu Slug | Menu Item |
|---|---|---|
/ |
your-plugin |
Dashboard |
/settings |
your-plugin-settings |
Settings |
/tables |
your-plugin-tables |
Tables |
/edit/:id |
your-plugin-tables |
Tables (grouped) |
How It Works Under the Hood
WordPress Admin Menu Structure
WordPress renders admin submenus with this HTML structure:
<ul class="wp-submenu">
<li class="current">
<a href="admin.php?page=your-plugin" class="current">Dashboard</a>
</li>
<li>
<a href="admin.php?page=your-plugin-settings">Settings</a>
</li>
</ul>
The .current class is what visually highlights the active menu item.
Our Hook's DOM Manipulation
-
Finds the target link using:
a[href*="page=your-plugin-settings"] -
Clears all
.currentclasses from sibling<li>and<a>elements -
Adds
.currentto the matching<li>and<a>
URL Update with replaceState
We use window.history.replaceState() instead of pushState() to:
- Update the
?page=parameter without page reload - Avoid polluting browser history with every route change
- Preserve React Router's hash-based navigation (
#/settings)
Troubleshooting
Menu Not Updating
- Check slug matching: Ensure your React route-to-slug mapping matches exactly what you registered in PHP
-
Verify selector: Use browser DevTools to confirm the submenu structure matches
ul.wp-submenu a[href*="page=..."]
URL Not Syncing
- Confirm you're using
HashRouter(notBrowserRouter) - Check that the hash is being preserved:
${window.location.hash}
Multiple Submenus Highlighted
- Ensure you're removing
.currentfrom all siblings before adding to the active item - Check for CSS specificity issues in your theme
Dynamic Routes (e.g., /edit/:id)
Group them with their parent route:
if (path === PATHS.TABLES || path.startsWith('/edit')) {
slug = 'your-plugin-tables';
}
Summary
By creating a custom React hook that:
-
Listens to route changes via
useLocation() - Maps routes to WordPress menu slugs
-
Updates the URL with
history.replaceState() -
Toggles the
.currentclass on DOM elements
You can achieve seamless synchronization between your React Router navigation and the WordPress admin sidebar, providing a polished, native-feeling admin experience.
Further Reading
This guide was created for developers building React-based WordPress plugins with client-side routing.
Top comments (0)