The WordPress Interactivity API, stable since WordPress 6.5, gives block developers a first-party way to add client-side interactivity without shipping a full React app to the front end. This guide covers everything you need to build real interactive blocks: the store model, every major directive, and three complete working examples.
What the Interactivity API Is (and Why It Replaced Ad-Hoc JS)
Before WP 6.5, interactive front-end blocks required each developer to invent their own JS wiring: a custom viewScript that grabbed DOM nodes by class name, attached listeners, and managed state in plain variables. That worked for one block. It broke down when two blocks on the same page needed to share state, or when a block needed to react to server-rendered HTML that changed on every request.
The Interactivity API solves this with a directive-based system built on Preact signals under the hood. You describe what the UI should do in HTML attributes (data-wp-*), and a tiny runtime handles the reactivity. The runtime is loaded once per page, no matter how many blocks use it.
Core WordPress uses the same API for the Query Loop pagination, the Search block, and the Image block lightbox. That means you are building on the same patterns the core team uses, and your block will behave consistently with built-in blocks on the same page.
Key concepts
- Store – the single source of truth per namespace, split into
state(reactive),actions(functions that mutate state), andeffects(side-effects that run when state changes) - Directives –
data-wp-*HTML attributes that bind DOM elements to store values - Server state –
wp_interactivity_state()in PHP seeds the initial state so the block renders correctly on first load - viewScriptModule – the block.json key that loads your ES module on the front end
- Context – per-element data set via
data-wp-context, allowing multiple instances of the same block to hold independent state without separate namespaces
Setting Up block.json for the Interactivity API
The critical change from a standard block is using viewScriptModule instead of viewScript. ES modules are required because the Interactivity API runtime is itself a module. Using viewScript with a file that contains import statements will produce a console error and the block will not be interactive.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "myplugin/toggle-block",
"title": "Toggle Block",
"category": "widgets",
"supports": {
"interactivity": true
},
"viewScriptModule": "file:./view.js",
"render": "file:./render.php"
}
Two things to notice: "supports": { "interactivity": true } tells WordPress to load the Interactivity API runtime on any page that renders this block. viewScriptModule (not viewScript) loads view.js as a native ES module so you can use import statements.
If your block also needs editor-side interactivity (not covered here), you would add editorScriptModule. For most interactive blocks, only the front-end view.js needs to be a module.
Example 1: Toggle Visibility Block
This block shows a button that hides and reveals a content panel. It is the simplest possible interactive pattern and demonstrates the three core files every interactive block needs.
render.php
<?php
// Seed initial state. The namespace must match what you use in view.js.
wp_interactivity_state( 'myplugin/toggle', array(
'isOpen' => false,
) );
?>
<div
data-wp-interactive="myplugin/toggle"
<?php echo get_block_wrapper_attributes(); ?>
>
<button
data-wp-on--click="actions.toggle"
data-wp-text="state.buttonLabel"
type="button"
></button>
<div data-wp-bind--hidden="!state.isOpen">
<?php echo wp_kses_post( $content ); ?>
</div>
</div>
view.js
import { store, getContext } from '@wordpress/interactivity';
store( 'myplugin/toggle', {
state: {
get buttonLabel() {
const ctx = getContext();
return ctx.isOpen ? 'Hide' : 'Show';
},
},
actions: {
toggle() {
const ctx = getContext();
ctx.isOpen = ! ctx.isOpen;
},
},
} );
What is happening here: data-wp-interactive on the root element declares the namespace. data-wp-on--click wires the button’s click event to actions.toggle. data-wp-text keeps the button label in sync with derived state. data-wp-bind--hidden adds or removes the HTML hidden attribute based on state.isOpen.
Example 2: Counter Block
The counter block adds two buttons (increment and decrement) and a live display. It shows how to read and write reactive state values directly.
render.php
<?php
wp_interactivity_state( 'myplugin/counter', array(
'count' => 0,
'step' => (int) ( $attributes['step'] ?? 1 ),
) );
?>
<div
data-wp-interactive="myplugin/counter"
<?php echo get_block_wrapper_attributes(); ?>
>
<button data-wp-on--click="actions.decrement" type="button">−</button>
<output data-wp-text="state.count">0</output>
<button data-wp-on--click="actions.increment" type="button">+</button>
</div>
view.js
import { store } from '@wordpress/interactivity';
const { state } = store( 'myplugin/counter', {
actions: {
increment() {
state.count += state.step;
},
decrement() {
state.count -= state.step;
},
},
} );
The state object returned by store() is reactive. Reading state.count inside an action, effect, or getter automatically subscribes to changes. Writing to it triggers any DOM bindings that reference it.
Notice that step is set from a block attribute in PHP. This means editors can configure the step value in the WordPress editor sidebar, and the front-end JS automatically receives the correct starting value via the seeded state.
Example 3: Live Fetch Block
This block loads a list of posts from the REST API on click, without a page reload. It demonstrates actions that are async generators, which is how the Interactivity API handles promises.
render.php
<?php
wp_interactivity_state( 'myplugin/live-fetch', array(
'posts' => array(),
'loading' => false,
'error' => '',
) );
?>
<div
data-wp-interactive="myplugin/live-fetch"
<?php echo get_block_wrapper_attributes(); ?>
>
<button
data-wp-on--click="actions.loadPosts"
data-wp-bind--disabled="state.loading"
type="button"
>
<span data-wp-text="state.buttonLabel"></span>
</button>
<p data-wp-text="state.error" data-wp-bind--hidden="!state.error"></p>
<ul>
<template
data-wp-each="state.posts"
data-wp-each-key="context.item.id"
>
<li data-wp-text="context.item.title.rendered"></li>
</template>
</ul>
</div>
view.js
import { store } from '@wordpress/interactivity';
const { state } = store( 'myplugin/live-fetch', {
state: {
get buttonLabel() {
return state.loading ? 'Loading...' : 'Load Posts';
},
},
actions: {
*loadPosts() {
state.loading = true;
state.error = '';
try {
const response = yield fetch(
'/wp-json/wp/v2/posts?per_page=5&_fields=id,title'
);
if ( ! response.ok ) {
throw new Error( `HTTP ${ response.status }` );
}
state.posts = yield response.json();
} catch ( err ) {
state.error = err.message;
} finally {
state.loading = false;
}
},
},
} );
Async actions use generator functions (function* / *methodName()). Every yield pauses the generator and hands control back to the runtime until the promise resolves. This is what lets the Interactivity API stay synchronous-looking while still being async-safe. data-wp-each on a <template> element renders a list item for every element in state.posts. The context for each iteration is available as context.item.
Using Effects for Side Effects
Effects run automatically when reactive state they depend on changes. They are the right tool for syncing state to external systems, like persisting a counter value to localStorage, or updating a URL query parameter when a filter changes.
import { store } from '@wordpress/interactivity';
const STORAGE_KEY = 'myplugin_counter';
const { state, effects } = store( 'myplugin/counter', {
state: {
count: parseInt( localStorage.getItem( STORAGE_KEY ) ?? '0', 10 ),
},
actions: {
increment() { state.count++; },
decrement() { state.count--; },
},
effects: {
persistCount() {
// Runs every time state.count changes.
localStorage.setItem( STORAGE_KEY, String( state.count ) );
},
},
} );
Effects are declared inside the store object and referenced in HTML with data-wp-run (runs reactively) or data-wp-init (runs once on mount). An effect function that reads state.count will automatically re-run whenever state.count changes, because the reactive system tracks the read.
Directive Reference
These are the directives you will use most often when building interactive blocks:
| Directive | What it does | Example |
|---|---|---|
data-wp-interactive | Declares the namespace for child directives | data-wp-interactive="myplugin/ns" |
data-wp-on--{event} | Attaches an event listener | data-wp-on--click="actions.doThing" |
data-wp-bind--{attr} | Sets an HTML attribute reactively | data-wp-bind--hidden="!state.open" |
data-wp-class--{cls} | Adds/removes a CSS class | data-wp-class--active="state.active" |
data-wp-text | Sets element text content | data-wp-text="state.label" |
data-wp-each | Loops over an array on a template element | data-wp-each="state.items" |
data-wp-each-key | Provides a stable key for list diffing | data-wp-each-key="context.item.id" |
data-wp-init | Runs a callback once when element mounts | data-wp-init="effects.setup" |
data-wp-run | Runs a callback reactively on state change | data-wp-run="effects.track" |
data-wp-context | Sets per-element context data as JSON | data-wp-context='{"id":1}' |
Server-Side State with wp_interactivity_state()
wp_interactivity_state() in PHP sets the initial state that the runtime reads before executing any JavaScript. This is what makes the Interactivity API SSR-compatible: the HTML that lands in the browser already has correct attribute values, and the runtime hydrates them rather than replacing them.
<?php
// In render.php - runs on every front-end request for this block.
wp_interactivity_state( 'myplugin/my-block', array(
'isExpanded' => (bool) get_post_meta( get_the_ID(), '_is_expanded', true ),
'apiNonce' => wp_create_nonce( 'wp_rest' ),
'postId' => get_the_ID(),
) );
?>
Values passed here are merged with any state defined in store() on the client. PHP wins for the initial render; the client takes over after hydration. Never put sensitive data here – it is serialised inline in the page HTML as a JSON script tag with id wp-interactivity-data.
Common Pitfalls to Avoid
- Using viewScript instead of viewScriptModule. The Interactivity API runtime is a module. If you load view.js as a classic script it cannot import from
@wordpress/interactivityand will throw a syntax error at theimportstatement. - Mismatched namespaces. The string in
data-wp-interactiveand the first argument tostore()must be identical. A typo silently disconnects the directive bindings from the store. - Forgetting supports.interactivity. Without
"interactivity": truein block.json supports, WordPress will not enqueue the runtime on pages where only your block appears. Core blocks that also use the API will load the runtime for themselves, which may mask this bug in testing. - Mutating state outside the store. The reactivity system only tracks reads and writes through the reactive proxy returned by
store(). Destructuring state into a local variable and mutating that local variable breaks reactivity entirely. - Async actions without yield. A regular
async/awaitaction will not work correctly inside the runtime. Use generator functions withyieldfor all async work. - Using getContext() outside a directive callback.
getContext()only works synchronously inside a directive callback. Calling it inside asetTimeoutor after ayieldreturnsundefined. - Sharing state across unrelated features. Use separate namespaces (e.g.
myplugin/feature-aandmyplugin/feature-b) rather than one flat store. This keeps state isolated and makes debugging straightforward.
Performance Notes
The Interactivity API runtime is approximately 12 KB gzipped and is loaded once, shared by all blocks on the page that declare "interactivity": true. Compare that to bundling React per-block, which adds 40+ KB per unique bundle.
Because the runtime uses Preact signals, updates are fine-grained: only DOM nodes that depend on a specific piece of state re-render when it changes. A counter that only touches state.count will never cause a re-render in a separate block that only reads state.isOpen, even if both blocks share the same store namespace.
The data-wp-each directive handles list diffing automatically using the key provided by data-wp-each-key. Always provide a stable key (an ID, not an array index) to avoid full list re-renders on state updates.
For blocks that appear many times on a single page (e.g. a card with an expand toggle), use data-wp-context to give each instance its own local data rather than storing per-instance state in the shared store. This keeps the store small and the diffing fast.
What Comes Next
This is the fifth article in the Advanced Block Theme Development series on Brndle. The earlier articles in the series covered block filters and hooks, custom block supports in theme.json, and the broader topic of what commercial block theme authors need to know as WordPress matures.
Once you have a working interactive block, the natural next step is adding server-rendered context per block instance. Each block rendered on the page can carry its own context object, set in render.php via data-wp-context, so the same store can power multiple independent block instances on the same page without state collision. The Interactivity API documentation in the Block Editor Handbook goes into per-instance context in detail if you want to continue from here.
You can also combine the wordpress interactivity api with server actions introduced in later WordPress versions to build blocks that write data back to the server without a full page reload. The pattern mirrors what you have seen in the fetch example, but the endpoint is a registered REST route instead of the public posts API.
Start Building
The WordPress Interactivity API gives block developers a clean, officially supported path to client-side interactivity. Set viewScriptModule in block.json, seed state with wp_interactivity_state() in PHP, define your store in view.js, and wire everything together with data-wp-* directives. No custom event buses, no global variables, no jQuery workarounds.
Browse the Brndle block development archive for more practical block guides, or start with the block filters and hooks tutorial if you need to modify existing block output before wiring up your own interactivity layer.
