Interactivity API
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
The Interactivity API is the standard way to make a server-rendered block react in the browser. The block’s PHP prints
HTML annotated with data-wp-* attributes (directives), WordPress evaluates them on the server so the first paint is
already correct, and a small JavaScript store, loaded as an ES module, takes over in the browser. This page, for plugin
and block developers, builds lumen/wishlist, the Lumen Press block that lets a signed-in reader save books from the
catalogue and shows the wishlist count in the site header, and uses it to cover the whole API.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 7.4 minimum and 8.3+ recommended, @wordpress/scripts 37.0.0 and Gutenberg 24.2.0,
and checked on 2026-10-28. WordPress 7.2 is in beta and due in December
2026. The block below was built with a real wp-scripts build --experimental-modules, served by WordPress 7.1.3 in
Docker with Twenty Twenty-Five, inspected with curl, and driven with Playwright in headless Chromium; the outputs shown
come from those runs, with the host name replaced by lumenpress.test. The build setup is in
Block Development Environment and the block.json fields in
block.json and Registration.
Why it exists
Before WordPress 6.5 each plugin solved front-end behaviour its own way: jQuery handlers bound to class names, which
duplicate the markup logic and drift from it; a React app mounted into a <div>, which shows nothing until its bundle
loads and ignores the HTML PHP produced; or ad hoc scripts. None let blocks from different plugins share state or survive
a client-side page change. The Interactivity API, merged in 6.5, standardises the pattern:
-
Server first and declarative. Blocks stay server-rendered, so content, SEO and the first paint do not depend on JavaScript; the JavaScript only changes state, and directives bound to it update the DOM, with no
querySelector. -
Small and shared. One runtime (
@wordpress/interactivity, Preact and signals) serves every interactive block, including core ones (Navigation, Image lightbox, Search, Query pagination, File). Stores are namespaced and global, so blocks can read each other’s state, and the router can replace parts of the page while keeping that state.
React remains the technology of the editor and admin screens. Older books predate the API: they show front-end code with
jQuery (sometimes a bare $, which fails because WordPress loads jQuery in no-conflict mode) and admin-ajax.php
requests without a nonce, and none covers Script Modules or the router. The current equivalent is a directive-driven
block that calls the REST API with a nonce, as below; the classic script path for code that is not a block is in
JavaScript, Ajax and Heartbeat.
How a request flows
Because the block declares supports.interactivity, WP_Block::render() passes its HTML through
wp_interactivity_process_directives(), which evaluates the directives with the HTML API and writes the results into the
markup. The state and config of every namespace are printed once, as JSON in a <script type="application/json">
tag. In the browser, @wordpress/interactivity reads that JSON, builds a virtual DOM of each data-wp-interactive
region and attaches the directives to the existing DOM without re-rendering it, so nothing flickers. From then on, actions
change state or context and only the directives that read the changed values re-run.
The lumen/wishlist block
The block has one attribute, display. As a toggle (the default) it goes inside the Query Loop of the /books/
archive and the single-book template, reads the book from the postId block context and renders an "Add to wishlist"
button. As a count it goes in the lumen theme’s header template part and renders a "Wishlist" button with the number
of saved books and a panel listing them. Both displays share one store namespace, lumen/wishlist, so saving a book on a
card updates the header immediately. Guests see a "Log in to save books" link instead, and readers without JavaScript
still get a working form.
The code lives in lumen-catalog: src/Wishlist.php (REST route, user meta, form fallback) and
src/blocks/wishlist/ with block.json, index.js (an editor preview only), render.php, view.js and style.scss.
Plugin::boot() calls Wishlist::boot() next to Blocks::boot(); Blocks registers every block in build/blocks/
from blocks-manifest.php, as shown in block.json and
Registration. npx @wordpress/create-block@5.0.0 wishlist --template @wordpress/create-block-interactive-template
scaffolds the same layout with a demo store (variant client-side-navigation adds router regions).
block.json
{
"$schema": "https://schemas.wp.org/wp/7.1/block.json",
"apiVersion": 3,
"name": "lumen/wishlist",
"version": "0.1.0",
"title": "Wishlist",
"category": "lumen-catalog",
"icon": "heart",
"description": "A wishlist button for the current book, or the reader's wishlist for the site header.",
"keywords": [ "favourites", "save for later" ],
"textdomain": "lumen-catalog",
"attributes": {
"display": {
"type": "string",
"enum": [ "toggle", "count" ],
"default": "toggle"
}
},
"usesContext": [ "postId", "postType" ],
"supports": {
"html": false,
"interactivity": true,
"spacing": {
"padding": true
}
},
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"viewScriptModule": "file:./view.js",
"render": "file:./render.php"
}
supports.interactivity takes true or an object with two booleans, and true means both. With interactive, the
block’s HTML goes through server directive processing (only the outermost interactive block is processed, so nested ones
are not processed twice) and its viewScriptModule is registered with fetchpriority low and printed in the footer.
With clientNavigation, the module tag gets data-wp-router-options='{"loadOnClientNavigation":true}', so the router
loads it on pages reached client-side; blocks without directives that still render correctly after a router navigation
set { "clientNavigation": true } alone, so they do not force full page loads.
viewScriptModule (6.5) is the front-end field for ES modules; the classic viewScript loads a regular script and
cannot import @wordpress/interactivity. The module is only built when wp-scripts build gets --experimental-modules
(the plugin’s npm run build). The second webpack pass writes view.js (1.7 KiB minified) and view.asset.php, with @wordpress/interactivity as a
dependency and 'type' => 'module'; the runtime is externalised and resolved through the import map, never bundled.
@wordpress/interactivity 8.0.0 is listed in dependencies only for ESLint’s import/no-unresolved; the linter
(wp-scripts lint-js) then reported no problems.
The REST route and the user meta
The wishlist is stored in the user meta lp_wishlist as a list of array( 'book' => 123, 'added' => 1793059200 )
entries, each book at most once; Privacy and Personal Data exports,
erases and prunes the same meta. The block reads and writes it through lumen/v1/wishlist, which does not clash with the
plugin’s other routes (lumen/v1/books, lumen/v1/loans, lumen/v1/books/<id>/read and lumen/v1/books/<id>/stock):
<?php
namespace Lumen\Catalog;
use WP_REST_Request;
use WP_REST_Server;
defined( 'ABSPATH' ) || exit;
// The reader's wishlist: user meta lp_wishlist, a REST route and a no-JavaScript form handler.
final class Wishlist {
const META_KEY = 'lp_wishlist'; // List of array( 'book' => int, 'added' => int ).
// Called from Plugin::boot().
public static function boot(): void {
add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
add_action( 'admin_post_lumen_wishlist_toggle', array( self::class, 'handle_form' ) );
}
public static function register_routes(): void {
register_rest_route(
'lumen/v1',
'/wishlist',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'rest_response' ),
'permission_callback' => 'is_user_logged_in',
)
);
register_rest_route(
'lumen/v1',
'/wishlist/(?P<book>\d+)',
array(
'methods' => array( 'PUT', 'DELETE' ),
'callback' => array( self::class, 'rest_update' ),
'permission_callback' => 'is_user_logged_in',
'args' => array(
'book' => array(
'type' => 'integer',
'required' => true,
'validate_callback' => array( self::class, 'is_book' ),
),
),
)
);
}
// PUT adds the book, DELETE removes it; both return the whole list.
public static function rest_update( WP_REST_Request $request ) {
$book = (int) $request['book'];
'PUT' === $request->get_method() ? self::add( get_current_user_id(), $book ) : self::remove( get_current_user_id(), $book );
return self::rest_response();
}
public static function rest_response() {
return rest_ensure_response( array( 'items' => self::items( get_current_user_id() ) ) );
}
// Fallback for browsers without JavaScript: a plain form post to admin-post.php.
public static function handle_form(): void {
$book = isset( $_POST['book'] ) ? absint( $_POST['book'] ) : 0;
check_admin_referer( 'lumen_wishlist_toggle_' . $book );
if ( self::is_book( $book ) ) {
$user = get_current_user_id();
in_array( $book, self::book_ids( $user ), true ) ? self::remove( $user, $book ) : self::add( $user, $book );
}
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : home_url( '/' ) );
exit;
}
public static function is_book( $id ): bool {
return 'lp_book' === get_post_type( (int) $id ) && 'publish' === get_post_status( (int) $id );
}
public static function book_ids( int $user ): array {
$entries = get_user_meta( $user, self::META_KEY, true );
return is_array( $entries ) ? array_map( 'intval', array_column( $entries, 'book' ) ) : array();
}
// The shape the block's state uses: newest first, published books only.
public static function items( int $user ): array {
$items = array();
foreach ( array_reverse( self::book_ids( $user ) ) as $book ) {
if ( self::is_book( $book ) ) {
$items[] = array(
'id' => $book,
'title' => get_the_title( $book ),
'url' => get_permalink( $book ),
);
}
}
return $items;
}
private static function add( int $user, int $book ): void {
if ( in_array( $book, self::book_ids( $user ), true ) ) {
return; // Each book at most once; PUT is idempotent.
}
$entries = (array) get_user_meta( $user, self::META_KEY, true );
$entries[] = array( 'book' => $book, 'added' => time() );
update_user_meta( $user, self::META_KEY, array_values( array_filter( $entries ) ) );
}
private static function remove( int $user, int $book ): void {
$entries = (array) get_user_meta( $user, self::META_KEY, true );
$entries = array_filter(
$entries,
static function ( $entry ) use ( $book ) {
return is_array( $entry ) && (int) $entry['book'] !== $book;
}
);
update_user_meta( $user, self::META_KEY, array_values( $entries ) );
}
}
The route uses cookie authentication, which requires a wp_rest nonce in the X-WP-Nonce header; without it WordPress
treats the request as anonymous even when the cookie is valid. Against the test site, an anonymous GET and a cookie-only
PUT both returned 401 rest_forbidden, a PUT for a page ID returned 400 rest_invalid_param, and a valid request
returned {"items":[...]} with both books, newest first.
Where a proxy rejects PUT and DELETE, WordPress accepts a POST with X-HTTP-Method-Override: DELETE. The form
fallback has no admin_post_nopriv_ handler: guests have no wishlist.
render.php: state, context and directives on the server
<?php
// Server-side rendering of lumen/wishlist. $attributes['display'] is "toggle" or "count";
// $block->context['postId'] comes from the Query Loop or the single-book template.
use Lumen\Catalog\Wishlist;
defined( 'ABSPATH' ) || exit;
$lumen_book = (int) ( $block->context['postId'] ?? 0 );
if ( ! is_user_logged_in() ) {
if ( 'toggle' === $attributes['display'] && Wishlist::is_book( $lumen_book ) ) {
printf(
'<p %s><a href="%s">%s</a></p>',
get_block_wrapper_attributes(), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
esc_url( wp_login_url( get_permalink( $lumen_book ) ) ),
esc_html__( 'Log in to save books to your wishlist', 'lumen-catalog' )
);
}
return;
}
// Static settings for every block on the page.
wp_interactivity_config(
'lumen/wishlist',
array(
'endpoint' => rest_url( 'lumen/v1/wishlist' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
)
);
// Global state, shared by every lumen/wishlist block, plus server versions of the derived state.
$lumen_items = Wishlist::items( get_current_user_id() );
wp_interactivity_state(
'lumen/wishlist',
array(
'items' => $lumen_items,
'isOnline' => true,
'labelAdd' => __( 'Add to wishlist', 'lumen-catalog' ),
'labelRemove' => __( 'Remove from wishlist', 'lumen-catalog' ),
'labelError' => __( 'Could not update your wishlist. Try again.', 'lumen-catalog' ),
'count' => count( $lumen_items ), // Known at render time: a plain value.
'isEmpty' => array() === $lumen_items,
'inWishlist' => static function () {
$state = wp_interactivity_state();
$context = wp_interactivity_get_context();
return in_array( $context['bookId'], array_column( $state['items'], 'id' ), true );
},
'toggleLabel' => static function () {
$state = wp_interactivity_state();
return $state['inWishlist']() ? $state['labelRemove'] : $state['labelAdd'];
},
'isDisabled' => false,
)
);
if ( 'count' === $attributes['display'] ) :
$lumen_panel = wp_unique_id( 'lumen-wishlist-' );
?>
<div
<?php echo get_block_wrapper_attributes(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
data-wp-interactive="lumen/wishlist"
<?php echo wp_interactivity_data_wp_context( array( 'isOpen' => false ) ); ?>
data-wp-init="callbacks.refreshOnRestore"
data-wp-run="callbacks.useConnectionStatus"
data-wp-on-document--keydown="actions.closeOnEscape"
>
<button type="button" aria-controls="<?php echo esc_attr( $lumen_panel ); ?>"
data-wp-on--click="actions.togglePanel"
data-wp-bind--aria-expanded="context.isOpen"
>
<?php esc_html_e( 'Wishlist', 'lumen-catalog' ); ?>
<span class="lumen-wishlist__count" data-wp-text="state.count" data-wp-class--is-empty="state.isEmpty"></span>
</button>
<div id="<?php echo esc_attr( $lumen_panel ); ?>" class="lumen-wishlist__panel"
data-wp-bind--hidden="!context.isOpen"
data-wp-watch="callbacks.focusPanel"
>
<p data-wp-bind--hidden="!state.isEmpty"><?php esc_html_e( 'Your wishlist is empty.', 'lumen-catalog' ); ?></p>
<ul>
<template data-wp-each--book="state.items" data-wp-each-key="context.book.id">
<li><a data-wp-bind--href="context.book.url" data-wp-text="context.book.title"></a></li>
</template>
</ul>
</div>
</div>
<?php
return;
endif;
if ( ! Wishlist::is_book( $lumen_book ) ) {
return; // A toggle outside a book (for example in a page template) renders nothing.
}
$lumen_context = array(
'bookId' => $lumen_book,
'title' => get_the_title( $lumen_book ),
'url' => get_permalink( $lumen_book ),
'isSaving' => false,
'error' => '',
);
?>
<form
<?php echo get_block_wrapper_attributes(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"
data-wp-interactive="lumen/wishlist"
<?php echo wp_interactivity_data_wp_context( $lumen_context ); ?>
data-wp-on--submit="actions.toggle"
data-wp-class--is-saved="state.inWishlist"
>
<input type="hidden" name="action" value="lumen_wishlist_toggle">
<input type="hidden" name="book" value="<?php echo esc_attr( $lumen_book ); ?>">
<input type="hidden" name="_wpnonce" value="<?php echo esc_attr( wp_create_nonce( 'lumen_wishlist_toggle_' . $lumen_book ) ); ?>">
<button type="submit"
data-wp-text="state.toggleLabel"
data-wp-bind--aria-busy="context.isSaving"
data-wp-bind--disabled="state.isDisabled"
data-wp-style--cursor="state.cursor"
></button>
<span role="status" data-wp-text="context.error"></span>
</form>
The three server functions:
-
wp_interactivity_state( $namespace, $state )(6.5) merges into a namespace’s global state and returns it (with no argument inside a derived-state closure, 6.6, it returns the state being processed); each block instance calling it is harmless. -
wp_interactivity_config( $namespace, $config )(6.5) holds static settings the client reads withgetConfig(). -
wp_interactivity_data_wp_context( $context, $namespace = '' )(6.5) returns thedata-wp-context='...'attribute, JSON-encoded and escaped.
Derived state on the server. count, isEmpty, inWishlist, toggleLabel and isDisabled are getters in
view.js. If PHP did not define them, the server could not evaluate the directives that use them, and the first paint
would show an empty count and a blank button until JavaScript arrived. Values known at render time (count, isEmpty,
isDisabled) are plain values. inWishlist and toggleLabel depend on each card’s context, so they are closures (6.6)
that repeat the getters' logic, with wp_interactivity_get_context() (6.6) returning the context of the element being
processed (wp_interactivity_get_element(), 6.7, returns its attributes). Closures are not sent to the browser: WordPress
lists them under derivedStateClosures, and the client treats them as pending until the store defines its getters.
The directive syntax: data-wp-<name>--<suffix>="<path>", where the path starts with state., context., actions.
or callbacks., a leading ! negates the value, and a namespace:: prefix reads another store
(core/query::actions.navigate). Since 6.8 directives can read .length of strings and arrays on both sides.
view.js: the store
import {
store,
getConfig,
getContext,
getElement,
useEffect,
watch,
withScope,
withSyncEvent,
} from '@wordpress/interactivity';
// Sends the request with the REST nonce and returns the new list of items.
function* request( path, method ) {
const { endpoint, nonce } = getConfig();
const response = yield fetch( endpoint + path, {
method,
headers: { 'X-WP-Nonce': nonce },
} );
if ( ! response.ok ) {
throw new Error( `HTTP ${ response.status }` );
}
const data = yield response.json();
return data.items;
}
const { state, actions } = store( 'lumen/wishlist', {
state: {
// Derived state: getters recompute when what they read changes.
get count() {
return state.items.length;
},
get isEmpty() {
return state.items.length === 0;
},
get inWishlist() {
const { bookId } = getContext();
return state.items.some( ( item ) => item.id === bookId );
},
get toggleLabel() {
return state.inWishlist ? state.labelRemove : state.labelAdd;
},
get isDisabled() {
return ! state.isOnline || getContext().isSaving;
},
get cursor() {
return getContext().isSaving ? 'progress' : false;
},
},
actions: {
// preventDefault() needs the event synchronously, hence withSyncEvent();
// the generator lets the action wait for fetch() without losing its scope.
toggle: withSyncEvent( function* ( event ) {
event.preventDefault();
const context = getContext();
const adding = ! state.inWishlist;
const previous = state.items;
context.isSaving = true;
context.error = '';
// Optimistic update: the UI changes before the server answers.
state.items = adding
? [
{
id: context.bookId,
title: context.title,
url: context.url,
},
...previous,
]
: previous.filter( ( item ) => item.id !== context.bookId );
try {
state.items = yield* request(
`/${ context.bookId }`,
adding ? 'PUT' : 'DELETE'
);
} catch {
state.items = previous;
context.error = state.labelError;
} finally {
context.isSaving = false;
}
} ),
*refresh() {
try {
state.items = yield* request( '', 'GET' );
} catch {
// Keep the list the page was rendered with.
}
},
togglePanel() {
const context = getContext();
context.isOpen = ! context.isOpen;
},
closeOnEscape( event ) {
if ( event.key === 'Escape' ) {
getContext().isOpen = false;
}
},
},
callbacks: {
// data-wp-init: runs once, when the element is hydrated.
refreshOnRestore() {
const onPageShow = withScope( ( event ) => {
if ( event.persisted ) {
actions.refresh(); // Page came back from the back/forward cache.
}
} );
window.addEventListener( 'pageshow', onPageShow );
return () => window.removeEventListener( 'pageshow', onPageShow );
},
// data-wp-run: runs on every render, so it can use hooks.
useConnectionStatus() {
useEffect( () => {
const update = () => {
state.isOnline = window.navigator.onLine;
};
window.addEventListener( 'online', update );
window.addEventListener( 'offline', update );
return () => {
window.removeEventListener( 'online', update );
window.removeEventListener( 'offline', update );
};
}, [] );
},
// data-wp-watch: runs again whenever context.isOpen changes.
focusPanel() {
const { isOpen } = getContext();
const { ref } = getElement();
if ( isOpen ) {
ref.querySelector( 'a' )?.focus();
}
},
},
} );
// watch() (WordPress 7.0): a store-level effect not tied to any element.
watch( () => {
document.body.classList.toggle( 'has-wishlist-items', state.count > 0 );
} );
The store
store( namespace, definition, options ) registers or extends a namespace and returns its proxied state, actions and
callbacks. Calls with the same namespace, from any module, deep-merge, so another plugin can extend lumen/wishlist or
read its state. Server state is merged first, so state.items already holds the PHP list when the module runs.
| Part | Use in the wishlist |
|---|---|
|
Global, reactive data of the namespace: |
Derived state |
Getters in |
Context |
Per-element data from |
|
Functions called by event directives or by other code: |
|
Functions run by |
The scope helpers:
-
getContext()returns the context of the element whose directive is running;getElement()returns{ ref, attributes }, the DOM node (null during the first render insidedata-wp-run) and its attributes. -
getConfig()returns whatwp_interactivity_config()set; it is not reactive.getServerState()andgetServerContext()return snapshots of what the server sent for the current page, updated after a router navigation. -
withScope( fn )binds the current scope to a function the browser calls later (thepageshowlistener above, asetTimeout()callback); without itgetContext()throws "Cannot call getContext() when there is no scope". -
withSyncEvent( fn )(6.8) marks an action that needsevent.preventDefault(),event.stopPropagation(),event.stopImmediatePropagation()orevent.currentTarget. The runtime is moving to asynchronous actions that yield to the main thread first, for better Interaction to Next Paint; 7.1.3 still runs them synchronously but logs a deprecation warning (withSCRIPT_DEBUG) for unwrapped actions that touch those members.toggleneeds it;closeOnEscapeonly readsevent.key. -
watch( fn )(7.0) runsfnnow and whenever a reactive value it read changes, and returns anunwatchfunction. It has no element, sogetContext()andgetElement()throw inside it. Readingstore( 'core/router' ).state.url(populated on the server since 7.0) in awatch()is the way to send a page view on each client-side navigation. -
splitTask()yields to the main thread inside a long generator action. -
store( 'lumen/wishlist/private', definition, { lock: true } )makes a namespace private: a secondstore()call on it throws, unless it passes the same string lock.
Directives
| Directive | What it does (wishlist use) | Server |
|---|---|---|
|
Marks an interactive region and sets the default namespace of the directives inside it. Required on the block wrapper; elements outside any region are not hydrated. |
Yes |
|
Local context for the element and its descendants ( |
Yes |
|
Sets or removes an attribute: |
Yes |
|
Adds the class when the value is truthy ( |
Yes |
|
Sets an inline CSS property, removes it when the value is |
Yes |
|
Sets the element’s text content, never HTML ( |
Yes |
|
Renders the template once per array item, exposing it as |
Yes |
|
Runs an action on a DOM event of the element, |
No |
|
Added in 6.6 to run handlers after yielding to the main thread. Deprecated: 7.1.3 logs a warning asking for |
No |
|
Runs a callback when the element is created and again when anything it read changes; a returned function runs before the next run and on removal ( |
No |
|
Runs a callback once when the element is created; a returned function runs on removal ( |
No |
|
Runs a callback during every render of the element, so it can use the hooks exported by the package: |
No |
|
A stable identity for an element among its siblings, used when lists change and by the router. |
No |
|
Marks a part of the page the router replaces on navigation (see below). |
Yes |
Several directives of the same kind on one element use a unique ID after three hyphens (official since 6.9):
data-wp-on--click---analytics="actions.track" next to data-wp-on--click="actions.open". The older two-hyphen form
(data-wp-watch--log) still works in 7.1.3 with a warning, but the current Gutenberg plugin has removed it and reads
data-wp-on--click--log as an event called click--log. data-wp-ignore is deprecated since 6.9 and removed in
current Gutenberg releases. The Gutenberg plugin also adds data-wp-html with asDangerousHTML() for trusted HTML;
WordPress 7.1.3 does not include it.
Only the directives marked "Server" change the HTML before it is sent. Event, watch, init and run directives exist only in the browser, which is why the wishlist’s no-JavaScript form must work on its own.
What the server sends
curl with a signed-in reader’s cookie, for the /catalogue/ page holding the header count and a Query Loop of books
(markup abridged; the reader had already saved The Glass Orchard):
<span class="lumen-wishlist__count" data-wp-text="state.count" data-wp-class--is-empty="state.isEmpty">1</span>
<div hidden id="lumen-wishlist-2" class="lumen-wishlist__panel" data-wp-bind--hidden="!context.isOpen" ...>
<p hidden data-wp-bind--hidden="!state.isEmpty">Your wishlist is empty.</p>
<template data-wp-each--book="state.items" data-wp-each-key="context.book.id">...</template>
<li data-wp-each-child="lumen/wishlist::state.items"><a href="https://lumenpress.test/books/the-glass-orchard/"
data-wp-bind--href="context.book.url" data-wp-text="context.book.title">The Glass Orchard</a></li>
<!-- ... -->
<div data-wp-interactive="core/query" data-wp-router-region="query-1" data-wp-key="1" class="wp-block-query ...">
<form class="wp-block-lumen-wishlist is-saved" method="post" ... data-wp-interactive="lumen/wishlist" ...>
<button aria-busy="false" type="submit" data-wp-text="state.toggleLabel" ...>Remove from wishlist</button>
The processor filled the count, added hidden to the closed panel and to the "empty" message, rendered the list item,
added is-saved to the saved book and evaluated the button label through the PHP closures. disabled is absent because
isDisabled is false, and cursor is absent because the server has no cursor value. Near the end of the page:
<script type="application/json" id="wp-script-module-data-@wordpress/interactivity">
{"config":{"lumen/wishlist":{"endpoint":"https://lumenpress.test/wp-json/lumen/v1/wishlist","nonce":"<nonce>"}},
"state":{"lumen/wishlist":{"items":[{"id":5,"title":"The Glass Orchard","url":"https://lumenpress.test/books/the-glass-orchard/"}],
"isOnline":true,"labelAdd":"Add to wishlist","labelRemove":"Remove from wishlist",
"labelError":"Could not update your wishlist. Try again.","count":1,"isEmpty":false,"inWishlist":{},"toggleLabel":{},
"isDisabled":false},"core/router":{"url":"https://lumenpress.test/catalogue/"}},
"derivedStateClosures":{"lumen/wishlist":["state.inWishlist","state.toggleLabel"]}}
</script>
A page cache would serve one reader’s list and nonce to the next. Signed-in pages are normally excluded from page
caches; a block that must work on cached pages fetches personal data after load, as actions.refresh does.
Script Modules and import maps
The Script Modules API (6.5) loads native ES modules, separately from wp_enqueue_script(). Modules have IDs, are
deferred by nature and import dependencies by bare specifier, which an import map printed by WordPress resolves to URLs.
viewScriptModule in block.json is the usual way to register one: WordPress calls
wp_register_script_module() with an ID generated from the block name (lumen-wishlist-view-script-module), the
dependencies and version from view.asset.php, and, for interactive blocks, 'fetchpriority' => 'low' and
'in_footer' => true. The module is enqueued only on pages that render the block. The tags printed for the catalogue
page:
<script id="wp-importmap" type="importmap">
{"imports":{"@wordpress/interactivity":"https://lumenpress.test/wp-includes/js/dist/script-modules/interactivity/index.js?ver=efaa5193bbad9c60ffd1",
"@wordpress/interactivity-router":"https://lumenpress.test/wp-includes/js/dist/script-modules/interactivity-router/index.js?ver=a6e90b8896f83fb1b5dd", ...}}
</script>
<link rel="modulepreload" href="https://lumenpress.test/wp-includes/js/dist/script-modules/interactivity/index.js?ver=efaa5193bbad9c60ffd1" id="@wordpress/interactivity-js-modulepreload" fetchpriority="low">
<script data-wp-router-options="{"loadOnClientNavigation":true}" fetchpriority="low" id="lumen-wishlist-view-script-module-js-module" src="https://lumenpress.test/wp-content/plugins/lumen-catalog/build/blocks/wishlist/view.js?ver=6634b87f7ae557369037" type="module"></script>
Code that is not a block (a classic theme template, a shortcode, an admin screen) registers modules itself:
<?php
// Hooked to wp_enqueue_scripts.
wp_register_script_module(
'@lumen/shelf-filter',
plugins_url( 'build/shelf-filter.js', LUMEN_CATALOG_FILE ),
array(
'@wordpress/interactivity',
array( 'id' => '@wordpress/interactivity-router', 'import' => 'dynamic' ), // Mapped, not preloaded.
),
'0.1.0',
array( 'in_footer' => true, 'fetchpriority' => 'low' ) // 6.9; in_footer only matters in block themes.
);
wp_enqueue_script_module( '@lumen/shelf-filter' );
wp_interactivity()->add_client_navigation_support_to_script_module( '@lumen/shelf-filter' );
Run through wp eval-file, this printed the router in the import map without a modulepreload link (dynamic
dependencies load on first import()), a preload for @wordpress/interactivity and the module tag with
data-wp-router-options. Directives in classic theme templates need wp_interactivity_process_directives().
Other functions of the API: wp_enqueue_script_module(), wp_dequeue_script_module(), wp_deregister_script_module(),
the script_module_data_{$module_id} filter that prints JSON for a module (the mechanism behind the state above), and
wp_set_script_module_translations() (7.0), which loads JavaScript translations for a module that calls __(). The
wishlist sends its strings through state instead, translated in PHP, so the module needs no translation file. Modules
and classic scripts have separate dependency graphs: a module lists only module IDs as dependencies, so it cannot pull in
a classic script such as wp-api-fetch; the wishlist calls fetch() with the nonce instead.
Client-side navigation with the router
@wordpress/interactivity-router (bundled since 6.5) replaces parts of the page without a full reload. Its supported mode
is region-based: it fetches the new URL, replaces each element marked data-wp-router-region="<id>" (with
data-wp-interactive on the same element), loads new styles and modules marked loadOnClientNavigation, updates
history and announces the change to screen readers; state outside the regions survives. Full-page client-side navigation
is still experimental.
The core/router store exposes actions.navigate( href, options ) (options force, html, replace, timeout,
loadingAnimation, screenReaderAnnouncement), actions.prefetch( url ) and state.url. A block imports it
dynamically, so pages that never navigate do not download it:
import { store, withSyncEvent } from '@wordpress/interactivity';
store( 'lumen/shelf', {
actions: {
// preventDefault() stops the full page load, so the action needs withSyncEvent().
goToPage: withSyncEvent( function* ( event ) {
event.preventDefault();
const { actions } = yield import( '@wordpress/interactivity-router' );
yield actions.navigate( event.target.href );
} ),
},
} );
Lumen Press needs no router code of its own: with "Reload full page" off ("enhancedPagination": true), the core
Query Loop wraps itself in data-wp-router-region="query-1" and calls core/query::actions.navigate from its
pagination links. A block inside it without clientNavigation support makes it fall back to full page loads;
lumen/wishlist has it. In the test, page 2 loaded client-side (a variable set on window before the click survived),
its wishlist buttons were hydrated with the right labels, and saving a book there updated the header count outside the
region.
During navigation, state already on the client is kept and only new keys from the new page’s server data are added. When
the client must accept the server’s value (for example, if another tab changed the wishlist), a data-wp-watch callback
can copy getServerState().items into state.items; it re-runs on every navigation. Since 7.0, reading
state.navigation of core/router (the loading bar’s internal flags) logs a deprecation warning.
Verifying the block
A Playwright script in headless Chromium signed in as maria.editor (Editor, ID 2) through a generated auth cookie,
opened the catalogue, used the toggles and the header, followed the Query Loop’s "Next Page" link and reloaded. Its output:
initial count 1 | ledger button: Add to wishlist
body class before true
after add: count 2 | button: Remove from wishlist | class: wp-block-lumen-wishlist is-saved | url https://lumenpress.test/catalogue/
panel hidden? null | items: [ 'The Lighthouse Ledger', 'The Glass Orchard' ] | focused: The Lighthouse Ledger
after Escape hidden?
navigated to ?query-1-page=2 | page 2 button: Add to wishlist
after add on page 2: count 3
after remove on page 2: count 2
after reload: count 2 | ledger: Remove from wishlist
requests [ 'PUT /wp-json/lumen/v1/wishlist/4', 'PUT /wp-json/lumen/v1/wishlist/6', 'DELETE /wp-json/lumen/v1/wishlist/6' ]
console []
The form post was cancelled, the panel focused its first link and closed on Escape, and the console stayed empty with
SCRIPT_DEBUG on, so no deprecation warnings fired. With JavaScript disabled, the button for The Glass Orchard posted
the form to admin-post.php, which removed the book and redirected back to /catalogue/. Finally,
wp user meta get 2 lp_wishlist --format=json returned [{"book":4,"added":1793201218}].
Errors and pitfalls
| Symptom | Cause and fix |
|---|---|
The button label or count appears only after a moment, or the layout jumps |
A directive reads derived state the server does not know. Define it in |
Nothing happens on click; no errors |
The module is not loaded: |
|
|
Warning "Using the synchronous event.preventDefault() function in a store action without wrapping it in withSyncEvent() is deprecated" |
Wrap the action in |
A |
Use |
A list rendered with |
Object items need |
REST requests return 401 for a signed-in reader |
The |
Security
-
Server data is public. State, context and config are in the page source: no secrets, no other users' data.
-
The server decides. Optimistic updates are cosmetic;
permission_callback, argument validation and the nonce (orcheck_admin_referer()on the form) protect the data. -
Text, not HTML.
data-wp-textsetstextContent, so a title with markup cannot inject HTML; boundhrefandsrcvalues come from the server (get_permalink()), never from user input. The Gutenberg plugin’sdata-wp-htmlrendersasDangerousHTML()values without sanitising them. Escape directive values PHP prints withesc_attr(); the rest is in Security for Developers. -
Run a patched core. WordPress 7.1.4 (2026-10-28) fixed several stored XSS issues and a stored denial of service in the Interactivity API itself, so a site using directives needs that release (or a later one) whatever its own code does; the fixes do not change the API, and server-provided state still has to be escaped as above. Source: WordPress News: WordPress 7.1.4