Router regions can be placed anywhere on the page. Their behavior depends on where they sit relative to other interactive elements and other router regions:
As a standalone element — When a router region is not inside any existing data-wp-interactive element, it serves a dual role: it is the interactive boundary (since it also contains data-wp-interactive) and its content is updated during navigation:
<!-- Interactive boundary + navigable region -->
<p data-wp-text="state.message">Hello</p>
```
Inside an interactive element — When a router region is nested inside an element that already has data-wp-interactive, the region becomes part of that element's interactivity. The parent interactive element stays untouched during navigation, but the region's content is updated:
```html
This heading is never updated during navigation
<div
data-wp-interactive="myPlugin"
data-wp-router-region="myPlugin/posts"
>
<!-- This content is updated during navigation -->
</div>
```
Note that the router region still needs its own data-wp-interactive directive, even though it is already inside one.
Inside another router region — When a router region is nested inside another router region, it becomes part of the parent region. The parent region is updated as a single unit during navigation; the nested region is not processed independently:
html
<div data-wp-interactive="myPlugin" data-wp-router-region="myPlugin/main">
<!-- This inner region is part of "myPlugin/main" -->
<div
data-wp-interactive="myPlugin"
data-wp-router-region="myPlugin/sidebar"
>
<!-- Updated together with the parent region -->
</div>
</div>
实现导航
要触发客户端导航,你需要在 store 中定义一个 action,并使用 Interactivity API 指令将其连接到 DOM 事件。Actions 是在 store() 内部定义的函数,用于处理用户交互。当通过像 data-wp-on--click 这样的指令连接到元素时,每当该事件触发,action 就会运行。
Without keys, navigating between these two pages would cause the algorithm to patch the product-detail <section> into the product-list <section> (or vice versa) by position, potentially corrupting their internal state. With distinct keys, the algorithm recognizes they are different elements and cleanly replaces one with the other.
Choosing good key values
A key should be:
Stable: The same item should always produce the same key, regardless of its position in the list.
Unique among siblings: No two sibling elements should share the same key. Keys only need to be unique within their parent, not globally.
Use data-derived identifiers whenever possible — post IDs, term IDs, or any value that uniquely identifies the item. Avoid using array indices as keys, because indices change when items are reordered, added, or removed, which defeats the purpose of keying.
During client-side navigation, the client-side state persists while the server provides new state for the target page. In some cases, you may want parts of your client state to stay in sync with what the server provides for each page — for example, updating a product count that changes across pages, or resetting an "expanded" flag based on the new page's context.
Use getServerState() and getServerContext() to react specifically to server-provided values and selectively update the client state in a callback:
import{store,getContext,getServerState,getServerContext,}from'@wordpress/interactivity';const{state}=store('myPlugin',{callbacks:{syncWithServer(){constserverState=getServerState();constserverContext=getServerContext();constcontext=getContext();// Keep the product count in sync with the server across navigations.if(serverState.productCount!==undefined){state.productCount=serverState.productCount;}// Reset the expanded state based on the new page's context.if(serverContext.isExpanded!==undefined){context.isExpanded=serverContext.isExpanded;}},},});
By default, once a page is stored in the router's internal in-memory cache, subsequent navigations use the cached version without making a new network request. Use the force option to bypass the router's internal in-memory cache and re-fetch the page from the server:
// Force re-fetch with navigate().yieldactions.navigate('/products/',{force:true});// Force re-fetch with prefetch().yieldactions.prefetch('/products/',{force:true});
If you're using force: true to refresh a page after a mutation (POST, PUT, DELETE request), make sure the mutation has completed before navigating:
store('myPlugin',{actions:{deleteAndRefresh:function*(){// Wait for the deletion to complete.yieldfetch('/wp-json/wp/v2/posts/123',{method:'DELETE'});// Now refresh the page to show updated data.const{actions}=yieldimport('@wordpress/interactivity-router');yieldactions.navigate(window.location.href,{force:true});},},});
Using custom HTML
Instead of fetching a page from a URL, you can provide HTML directly using the html option:
// Navigate with custom HTML.yieldactions.navigate('/custom-page/',{html:` <div data-wp-interactive="myPlugin" data-wp-router-region="myPlugin/content"> <h1>Custom Content</h1> <p>This HTML was provided directly, not fetched.</p> </div> `,});// Prefetch with custom HTML.yieldactions.prefetch('/custom-page/',{html:customHtmlString,});
This is useful when you need to control the fetch request yourself.
When navigation fails (network error, timeout, or server error), the router automatically falls back to a full page reload. This means you cannot catch fetch errors from navigate() directly — the browser takes over before your code has a chance to handle them.
If you need custom error handling (for example, showing an error message instead of reloading), you can fetch the page manually, handle any errors yourself, and then pass the fetched HTML to navigate() using the html option:
store('myPlugin',{actions:{navigateWithCustomErrorHandling:withSyncEvent(function*(event){event.preventDefault();consturl=event.target.href;try{// Fetch the page manually.constresponse=yieldfetch(url);if(!response.ok){// Handle HTTP errors.state.error=`Error: ${response.status}`;return;}consthtml=yieldresponse.text();// Navigate using the fetched HTML.const{actions}=yieldimport('@wordpress/interactivity-router');yieldactions.navigate(url,{html});}catch(error){state.error='Network error. Please check your connection.';}}),},});
Disabling client-side navigation on certain pages
Some pages may require a full page reload instead of client-side navigation. Use wp_interactivity_config() to disable client navigation:
// In your theme's functions.php or a plugin.add_action( 'wp', function() { // Disable on specific page templates. if ( is_page_template( 'template-complex.php' ) ) { wp_interactivity_config( 'core/router', array( 'clientNavigationDisabled' => true ) ); }} );
When clientNavigationDisabled is true:
actions.navigate() triggers a full page reload.
actions.prefetch() does nothing.
Navigating from another page to this page forces a reload.
At the heart of the Interactivity API router is an internal in-memory page cache — a simple store that maps URLs to their processed page representations. When you call prefetch() or navigate(), the router first checks this cache to see if the target page has already been fetched and processed.
The cache uses a normalized version of the URL as its key. This normalization strips away the domain and any hash fragments, keeping only the pathname and query parameters. For example, https://example.com/products/?category=shoes#details becomes /products/?category=shoes. This ensures that navigations to the same logical page (regardless of how the URL was constructed) share the same cache entry.
Each entry in the cache stores not just the fetched HTML, but a fully processed page representation containing:
Virtual DOM trees for each router region found in the page
Style sheet references needed by the page
Script module information for JavaScript that should be loaded
The page title for updating the document title
Server state that was embedded in the page by WordPress
An important detail is that the cache stores promises rather than resolved values. When a fetch begins, the router immediately stores the pending promise in the cache. This means that if multiple calls to prefetch() or navigate() target the same URL simultaneously (for example, if a user rapidly hovers over multiple links pointing to the same page), only one network request is made. All callers receive the same promise and wait for the same result.
Once a page is in the cache, it remains there for the duration of the browser session. Subsequent navigations to that URL will use the cached version instantly, without any network request. This is why client-side navigation feels so fast after the initial visit — the page is already prepared and ready to render.
If you need to force a fresh fetch (for example, after submitting a form that changes the page's content), you can use the force: true option with navigate() or prefetch(). This bypasses the cache check and fetches the page anew, replacing the existing cache entry with the fresh content.
// Force a fresh fetch after a form submission.const{actions}=store('myPlugin',{actions:{*submitForm(){yieldfetch('/wp-json/my-plugin/v1/submit',{method:'POST',body:JSON.stringify({/* form data */}),});// Navigate to the same page, bypassing the cache// to reflect the updated content.yieldnavigate(window.location.href,{force:true});},},});
Router regions
Router regions are the sections of your page that the router knows how to update during client-side navigation. They act as boundaries that tell the router "this is the content that should change when navigating between pages."
Defining router regions
You define a router region by adding the data-wp-router-region attribute to an element alongside data-wp-interactive (as described in Setting up router regions above).
The attribute value serves as a unique identifier for that region. You can specify it in two ways:
As a JSON object (when you need to pass other options):
html
<div
data-wp-interactive="myPlugin"
data-wp-router-region='{ "id": "myPlugin/modal", "attachTo": "body" }'
>
<!-- Region content -->
</div>
The region ID must be unique within a single page and consistent across pages that share the same region. For example, if both your "Products" page and "Product Detail" page have a sidebar, and you want that sidebar to update during navigation, both pages should define a region with the same ID (e.g., "myPlugin/sidebar").
How regions are processed during page fetch
When the router fetches a new page (either through prefetch() or as part of navigate()), it processes the HTML to extract and prepare all router regions. This happens in several steps:
First, the router parses the fetched HTML into a document structure using the browser's built-in HTML parser. This gives it a complete DOM tree to work with, just as if the page had been loaded normally.
Next, the router scans this document for all elements that have both data-wp-interactive and data-wp-router-region attributes. For each region found, it extracts the region ID and checks whether the region is nested inside another region. Only top-level regions are processed directly; nested regions are handled as part of their parent's content.
For each top-level region, the router converts the HTML into a virtual DOM (vDOM) representation. The virtual DOM is a lightweight JavaScript object structure that mirrors the actual DOM but can be compared and manipulated much more efficiently by the Interactivity API. Importantly, the region element itself is included in this conversion — not just its children. This means that attributes on the region element, such as data-wp-context, will also be processed and updated during navigation.
Finally, each region's virtual DOM is stored in the page cache entry, indexed by its region ID. The cache entry now contains a map of region IDs to their corresponding virtual DOM trees.
The attachTo property contains a CSS selector that identifies where in the current page the new region should be appended. When the router encounters such a region, it:
Finds the element matching the attachTo selector in the current page
Creates new DOM elements for the region
Appends these elements to the matched parent
Renders the region's virtual DOM into the newly created elements
This allows content that exists on one page but not another to appear smoothly during navigation, without requiring the target element to exist in advance.
Scenario 3: Region exists only on the current page (remove)
When a region exists on the current page but not on the target page, it means that content is no longer needed. The router handles this by setting the region's content to empty, effectively clearing it from the display.
If the region was dynamically created via attachTo during a previous navigation, the entire region element is removed from the DOM. If it was part of the original page structure, the element remains but its content is cleared.
What happens to HTML outside router regions?
An important detail to understand is that HTML outside of router regions remains completely untouched during client-side navigation. The router only modifies the content inside the regions it manages — everything else in the DOM stays exactly as it was.
This means that if you have static elements like a site header, footer, or navigation menu that aren't wrapped in a router region, they won't change when the user navigates between pages. This can be intentional (for elements that truly are the same across all pages) or it can be a source of confusion if you expect those elements to update.
However, there's an important exception: interactive elements outside router regions can still react to global state changes. If you have an interactive element outside any router region, with directives that use getServerState() to read global state, these directives will automatically re-evaluate when navigation brings in new server state.
For example, consider a shopping cart icon in the header that displays the number of items:
<!-- This header is NOT inside a router region --><headerdata-wp-interactive="myShop"><divclass="cart-icon"><spandata-wp-text="state.cartCount"></span> items
</div></header><!-- This is the router region that updates during navigation --><maindata-wp-interactive="myShop"data-wp-router-region="myShop/content"><!-- Page content --></main>
If state.cartCount comes from the regular client-side state, the cart icon will not update during navigation — even if the new page has a different cart count in its server state. The header, while being interactive, is outside any router region, so it's not re-rendered.
But if you use getServerState() instead:
const{state}=store('myShop',{state:{getcartCount(){// This reacts to server state changes during navigation.returngetServerState().cartCount;},},});
Now the cart icon will update whenever navigation brings in a new cartCount value from the server, even though the header itself is outside any router region. This is because getServerState() creates a reactive subscription to server-provided state, which is updated during every navigation.
This pattern is useful for global UI elements that need to stay synchronized with server data across navigations, without requiring them to be inside a router region. However, getServerState() can also be used to synchronize the state of interactive elements inside router regions, as described in the Handling server state updates section.
每个脚本模块的顶层代码都会运行,这通常包括调用 store() 来注册操作、回调和状态。由于交互性 API 的存储是全局且可累加的,这些注册会与初始页面加载时已有的存储定义合并。
Server state and context
Interactive elements often need data from the server — configuration values, content from the database, user preferences, and more. The Interactivity API provides three mechanisms for this: global state, local context and config.
During client-side navigation, this server-provided data needs to be extracted from the new page and made available to the client-side code.
How server data is embedded in pages
When WordPress renders a page with interactive elements, it embeds server-provided data in special <script> tags:
<!-- Global state --><scripttype="application/json"id="wp-script-module-data-@wordpress/interactivity">{"state":{"myPlugin":{"cartItemCount":3,}}"config":{"myPlugin":{"userLoggedIn":true}}}</script>
Local context is embedded directly in the data-wp-context attribute of elements:
When the router fetches a new page, it extracts these types of server data:
Global state: The router finds the <script type="application/json"> element with ID wp-script-module-data-@wordpress/interactivity and parses its JSON content to extrat its state property. This state comes from wp_interactivity_state is stored in the internal in-memory page cache entry.
Local context: Context values are embedded in the virtual DOM representation of each router region. When a region's HTML is converted to vDOM, the data-wp-context attributes are preserved and will be processed during rendering.
Config: The router finds the <script type="application/json"> element with ID wp-script-module-data-@wordpress/interactivity and parses its JSON content to extrat its config property. This configuration comes from wp_interactivity_config and is stored in the internal in-memory page cache entry.
Merging server data during navigation
When navigation renders the new page, the server-provided data may need to merge with the existing client-side state, depending on the use case. The key principle here is that client-side state is never automatically overwritten by the server. This design ensures that any changes your JavaScript code has made to the state (such as user preferences, UI toggles, or form input) are preserved across navigations.