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

sequenceDiagram participant R as render.php participant P as Server directive processing participant B as Browser participant S as view.js store participant A as REST lumen/v1/wishlist R->>P: wp_interactivity_state(), context and markup with data-wp-* directives P->>B: HTML with count, labels and list already filled in, plus state and config as JSON Note over B: First paint, before any JavaScript runs B->>S: Script modules load, store() registers, regions hydrate Note over B,S: Hydration binds directives to the existing DOM without rebuilding it B->>S: Submit on a book card runs actions.toggle S->>B: state.items changes, signals re-run only the affected directives S->>A: PUT /lumen/v1/wishlist/4 with X-WP-Nonce A-->>S: items saved in user meta lp_wishlist S->>B: state.items replaced with the server list, directives re-render

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 with getConfig().

  • wp_interactivity_data_wp_context( $context, $namespace = '' ) (6.5) returns the data-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

state

Global, reactive data of the namespace: items, isOnline, the translated labels. One copy per page, shared by every block instance.

Derived state

Getters in state (count, inWishlist, toggleLabel). They are cached and recomputed only when what they read changes. Use them, not actions, for every value a directive displays or binds; since 6.8 a function called through ! in a directive triggers a deprecation warning, and current Gutenberg releases resolve it to undefined.

Context

Per-element data from data-wp-context, inherited by descendants and merged with ancestors' context: bookId, title, url, isSaving and error on each card, isOpen on the header block. Read and written with getContext().

actions

Functions called by event directives or by other code: toggle, refresh, togglePanel, closeOnEscape. Asynchronous actions are generator functions that yield promises, not async functions, because the runtime must restore the scope (getContext(), getElement()) each time the generator resumes; yield* delegates to a helper generator such as request().

callbacks

Functions run by data-wp-init, data-wp-watch and data-wp-run for side effects. A callback can return a cleanup function.

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 inside data-wp-run) and its attributes.

  • getConfig() returns what wp_interactivity_config() set; it is not reactive. getServerState() and getServerContext() 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 (the pageshow listener above, a setTimeout() callback); without it getContext() throws "Cannot call getContext() when there is no scope".

  • withSyncEvent( fn ) (6.8) marks an action that needs event.preventDefault(), event.stopPropagation(), event.stopImmediatePropagation() or event.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 (with SCRIPT_DEBUG) for unwrapped actions that touch those members. toggle needs it; closeOnEscape only reads event.key.

  • watch( fn ) (7.0) runs fn now and whenever a reactive value it read changes, and returns an unwatch function. It has no element, so getContext() and getElement() throw inside it. Reading store( 'core/router' ).state.url (populated on the server since 7.0) in a watch() 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 second store() call on it throws, unless it passes the same string lock.

Directives

Directive What it does (wishlist use) Server

data-wp-interactive="lumen/wishlist"

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

data-wp-context='{...}'

Local context for the element and its descendants (bookId, isOpen).

Yes

data-wp-bind--<attr>

Sets or removes an attribute: true adds a boolean attribute, false, null or undefined removes it; aria- and data- attributes get the string "true"/"false" (aria-expanded, aria-busy, hidden, disabled, href).

Yes

data-wp-class--<class>

Adds the class when the value is truthy (is-saved, is-empty).

Yes

data-wp-style--<property>

Sets an inline CSS property, removes it when the value is false (cursor: progress while saving).

Yes

data-wp-text

Sets the element’s text content, never HTML (count, toggleLabel, error).

Yes

data-wp-each--<item> on a <template>

Renders the template once per array item, exposing it as context.<item> (default item); data-wp-each-key gives a stable key for object items. The server renders the items after the template and marks them data-wp-each-child.

Yes

data-wp-on--<event>, data-wp-on-window--<event>, data-wp-on-document--<event>

Runs an action on a DOM event of the element, window or document (submit, click, keydown for Escape).

No

data-wp-on-async--<event> and its window/document forms

Added in 6.6 to run handlers after yielding to the main thread. Deprecated: 7.1.3 logs a warning asking for data-wp-on with withSyncEvent() where needed.

No

data-wp-watch

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 (focusPanel).

No

data-wp-init

Runs a callback once when the element is created; a returned function runs on removal (refreshOnRestore).

No

data-wp-run

Runs a callback during every render of the element, so it can use the hooks exported by the package: useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, useInit, useWatch (useConnectionStatus).

No

data-wp-key

A stable identity for an element among its siblings, used when lists change and by the router.

No

data-wp-router-region

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="{&quot;loadOnClientNavigation&quot;: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 wp_interactivity_state(), as a value or a closure.

Nothing happens on click; no errors

The module is not loaded: viewScriptModule missing, the build run without --experimental-modules, or the markup lacks data-wp-interactive. Check the page for the ...-view-script-module-js-module tag.

Cannot call getContext() when there is no scope.

getContext() or getElement() used after an await, in a setTimeout() or event listener, or inside watch(). Use a generator instead of async, or wrap the callback in withScope().

Warning "Using the synchronous event.preventDefault() function in a store action without wrapping it in withSyncEvent() is deprecated"

Wrap the action in withSyncEvent().

A data-wp-context built with json_encode() breaks on an apostrophe in a title

Use wp_interactivity_data_wp_context(), which encodes and escapes the attribute.

A list rendered with data-wp-each duplicates or loses items when it changes

Object items need data-wp-each-key on the <template>, not data-wp-key inside it.

REST requests return 401 for a signed-in reader

The X-WP-Nonce header is missing or expired; the nonce printed in config lives 12 to 24 hours, so long-lived or cached pages need to refresh it.

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 (or check_admin_referer() on the form) protect the data.

  • Text, not HTML. data-wp-text sets textContent, so a title with markup cannot inject HTML; bound href and src values come from the server (get_permalink()), never from user input. The Gutenberg plugin’s data-wp-html renders asDangerousHTML() values without sanitising them. Escape directive values PHP prints with esc_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