WordPress Interactivity API: Building Reactive Islands Without React

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.