WordPress 6.5 stabilised the Interactivity API — a minimal reactive system built into WordPress core that lets you add client-side state, actions, and derived values to server-rendered blocks using data-wp-* directives and a store() function. It is purpose-built for progressive enhancement and works without React, without a build step for the HTML, and with full server-side rendering compatibility.
Problem: Gutenberg blocks with interactive features — a real-time search filter, a paginated carousel, a cart quantity stepper — need client-side state that updates the DOM without a full page reload, but wiring this up with vanilla JavaScript requires reinventing a reactive system.
Solution: Use the WordPress Interactivity API (stable since WordPress 6.5) — declare state with store() in a JavaScript module, bind it to DOM elements with data-wp-bind, data-wp-text, and data-wp-on--click directives, and let the Interactivity API runtime reconcile DOM updates. Server-rendered HTML is hydrated automatically.
The example below builds a filterable tag cloud block: the PHP render callback outputs the markup with data-wp-* directives, and the JavaScript store handles the filter logic — all reactive, with no full-page reload.
<?php
// block.json registers viewScriptModule: 'file:./view.js'
// render.php
$tags = get_tags( [ 'hide_empty' => true, 'number' => 50 ] );
if ( empty( $tags ) ) { return; }
// Provide initial server state via wp_interactivity_state()
wp_interactivity_state( 'tag-cloud', [
'filter' => '',
'allTags' => array_map( fn($t) => [
'id' => $t->term_id,
'name' => $t->name,
'count' => $t->count,
'url' => get_tag_link( $t->term_id ),
], $tags ),
] );
?>
<div
<?php echo get_block_wrapper_attributes(); ?>
data-wp-interactive="tag-cloud"
>
<input
type="search"
placeholder="Filter tags…"
data-wp-bind--value="state.filter"
data-wp-on--input="actions.setFilter"
>
<ul class="tag-list">
<?php foreach ( $tags as $tag ) : ?>
<li
data-wp-bind--hidden="!state.tagVisible"
data-wp-context='<?php echo wp_json_encode( [ "tagName" => strtolower( $tag->name ) ] ); ?>'
data-wp-class--is-hidden="!callbacks.isVisible"
>
<a href="<?php echo esc_url( get_tag_link( $tag->term_id ) ); ?>">
<?php echo esc_html( $tag->name ); ?>
<span class="count">(<?php echo (int) $tag->count; ?>)</span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
// view.js (registered as a Script Module)
import { store, getContext, getElement } from '@wordpress/interactivity';
store( 'tag-cloud', {
actions: {
setFilter( event ) {
const { state } = store( 'tag-cloud' );
state.filter = event.target.value.toLowerCase().trim();
},
},
callbacks: {
isVisible() {
const { state } = store( 'tag-cloud' );
const { tagName } = getContext();
return state.filter === '' || tagName.includes( state.filter );
},
},
} );
NOTE: wp_interactivity_state() is merged server-side; data passed to it is inlined in the page as a JSON script tag and is publicly visible in the HTML source — never pass nonces, capability flags, or sensitive data through wp_interactivity_state(); use wp_localize_script() with a nonce for any action that requires authentication.