
How to Add Interactivity to Blocks with the WordPress Interactivity API?
Static blocks are easy in WordPress. Blocks that respond to the visitor are where things used to get messy: a toggle here, a tab set there, a "load more" button somewhere else, each one shipped with its own jQuery snippet or a small React app, its own way of reading data from the page, and its own bugs when two of them land on the same template. The output looked fine in the editor, then broke when a caching plugin minified the scripts or another block reused the same class names.
The Interactivity API is WordPress core's answer to that problem. It gives every block the same small, declarative system for front-end behavior: you write HTML with data-wp-* directives in PHP, WordPress renders it on the server, and a tiny shared runtime makes it reactive in the browser. No hand-written DOM queries, no duplicated framework bundles, and the markup is complete before any JavaScript runs.
This article covers what the Interactivity API is and how it works, scaffolding an interactive block, the roles of block.json, render.php, and view.js, the core directives, global state versus local context, and a complete "load more posts" block that fetches data from the REST API. It ends with the problems people hit most often and how to fix them.
What Is the WordPress Interactivity API?
The Interactivity API is a standard for adding front-end interactions to blocks. It shipped in WordPress 6.5 and has been extended in every release since. It has three parts:
- Directives. HTML attributes such as
data-wp-on--clickanddata-wp-bind--hiddenthat connect markup to behavior. You write them in PHP, in your block's render output. - Stores. A JavaScript module per namespace that holds
state,actions, andcallbacks. Directives reference values in the store by path, likeactions.toggleorstate.isOpen. - The runtime. The
@wordpress/interactivityscript module, loaded once per page, which hydrates directives and keeps the DOM in sync with state. It is built on Preact and signals, so it is small and fast.
Two design decisions make it different from dropping a React app into a block:
- Server-side rendering of directives. WordPress processes directives in PHP before sending the page. If the initial state says a panel is hidden, the HTML already contains the
hiddenattribute. Visitors see the correct UI before JavaScript loads, and search engines see the real content. - Shared runtime. Every interactive block on a page uses the same runtime and can read each other's stores. Ten interactive blocks do not mean ten frameworks.
| Approach | Initial HTML correct without JS | Shared across blocks | Typical size per block |
|---|---|---|---|
jQuery snippet in viewScript | Usually not | No | Small, plus jQuery |
| React app mounted in a block | No, renders after load | No | Large |
| Interactivity API | Yes, directives run in PHP | Yes | Very small store file |
Core blocks use it too. The Image block lightbox, the Query block's instant pagination, the Navigation block's overlay menu, and the Search block's expandable button are all built with the Interactivity API, which is a good sign it is the long-term direction for front-end behavior in WordPress.
How the Pieces Fit Together
An interactive block is a dynamic block with three key files:
| File | Runs where | Job |
|---|---|---|
block.json | Build and PHP | Declares interactivity support and the view script module |
render.php | Server | Outputs HTML with directives, sets initial state and context |
view.js | Browser | Defines the store: state getters, actions, and callbacks |
The request flow looks like this:
- WordPress renders the block by including
render.php. - Your PHP calls
wp_interactivity_state()to define initial global state and prints the markup withdata-wp-*attributes. - Because the block declares
supports.interactivity, WordPress processes the directives on the server and outputs correct initial HTML. - The page loads
view.jsas a script module, which callsstore()to register actions and derived state. - The runtime hydrates the block. Clicking a button runs an action, the action changes state, and every directive that depends on that state updates automatically.
Scaffolding an Interactive Block
The fastest start is the official template for @wordpress/create-block. You need Node.js and npm installed. Run this inside wp-content/plugins of a development site:
# Terminal
cd wp-content/plugins
npx @wordpress/create-block@latest wsm-load-more --template @wordpress/create-block-interactive-template
cd wsm-load-more
npm start
The command creates a plugin with a working example block, installs dependencies, and adds two npm scripts:
"scripts": {
"build": "wp-scripts build --experimental-modules --blocks-manifest",
"start": "wp-scripts start --experimental-modules --blocks-manifest"
}
The --experimental-modules flag is important. It tells @wordpress/scripts to build view.js as a native JavaScript module so it can import @wordpress/interactivity. If you add interactivity to an existing block, add the flag to your build scripts yourself.
Activate the plugin under Plugins, add the block to a post, and view the front end. The example block has a toggle button and a theme switch, which is enough to see the system working before you replace it with your own code.
The main plugin file registers every block in the build folder with one call:
<?php
// wp-content/plugins/wsm-load-more/wsm-load-more.php
function wsm_load_more_block_init() {
wp_register_block_types_from_metadata_collection(
__DIR__ . '/build',
__DIR__ . '/build/blocks-manifest.php'
);
}
add_action( 'init', 'wsm_load_more_block_init' );
wp_register_block_types_from_metadata_collection() was added in WordPress 6.8 and reads the manifest that --blocks-manifest generates. On older versions you would call register_block_type( __DIR__ . '/build/wsm-load-more' ) instead.
The Three Files of an Interactive Block
block.json
The block metadata in src/wsm-load-more/block.json declares that the block is interactive and points to the view module:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "wsm/load-more",
"title": "Load More Posts",
"category": "widgets",
"icon": "update",
"description": "Lists recent posts and loads more without a page reload.",
"attributes": {
"perPage": { "type": "number", "default": 3 }
},
"supports": {
"interactivity": true
},
"textdomain": "wsm-load-more",
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"render": "file:./render.php",
"viewScriptModule": "file:./view.js"
}
supports.interactivity: truetells WordPress to process directives in this block's output on the server.viewScriptModuleloadsview.jsas a script module on the front end, only on pages where the block appears. UseviewScriptModule, notviewScript, because the store must import the runtime as a module.rendermakes it a dynamic block, so the markup comes from PHP on every request.
render.php
render.php outputs the markup. WordPress exposes three variables to it: $attributes, $content, and $block. Two helper functions do most of the work:
wp_interactivity_state( $namespace, $state )sets global state for a namespace. It is merged with any state set by other blocks using the same namespace and serialized into the page for the JavaScript store.wp_interactivity_data_wp_context( $context )returns a correctly escapeddata-wp-contextattribute for local context.
view.js
view.js defines the store with store( namespace, definition ). The definition can contain:
state: global values and getters for derived stateactions: functions triggered by events, such as clickscallbacks: functions run by directives such asdata-wp-watchanddata-wp-init
Use getContext() inside any of them to read and change the local context of the element that triggered it.
The Core Directives
Directives are attributes in the form data-wp-<name>--<suffix>="<path>". The value is a reference to a store path, optionally negated with !.
| Directive | What it does | Example |
|---|---|---|
data-wp-interactive | Activates the API and sets the store namespace | data-wp-interactive="wsm/load-more" |
data-wp-context | Defines local state for this element and its children | Printed with wp_interactivity_data_wp_context() |
data-wp-on--{event} | Runs an action on a DOM event | data-wp-on--click="actions.loadMore" |
data-wp-bind--{attr} | Binds an HTML attribute to a value | data-wp-bind--hidden="!context.hasMore" |
data-wp-class--{name} | Adds or removes a class | data-wp-class--is-loading="context.isLoading" |
data-wp-style--{prop} | Sets an inline style property | data-wp-style--width="state.progress" |
data-wp-text | Sets the element's text content | data-wp-text="context.post.title" |
data-wp-each--{item} | Renders a list from an array, inside a template tag | data-wp-each--post="context.posts" |
data-wp-watch | Runs a callback whenever values it reads change | data-wp-watch="callbacks.logState" |
data-wp-init | Runs a callback once when the element is created | data-wp-init="callbacks.setup" |
A few rules save debugging time:
- The namespace is inherited. Everything inside an element with
data-wp-interactiveuses that store unless it declares its own. - Context is inherited and merged. Child elements see their parents' context, and a nested
data-wp-contextadds or overrides keys. data-wp-textsets text, not HTML. It is safe against injection, but HTML entities in your data will show literally, so decode them first.
Global State vs Local Context
Choosing where data lives is the most important design decision in an interactive block.
- Global state (
state) is shared by every element using the namespace, across every instance of the block on the page. Use it for configuration and values that must stay in sync everywhere, like a REST URL, a cart count, or a site-wide dark mode flag. - Local context (
context) belongs to one element and its children. Use it for anything that is per-instance: whether this accordion item is open, which page this list has loaded, which tab is active. - Derived state is a getter in
statethat computes a value from other state or context. It keeps your data minimal and the markup simple.
If you put per-instance data in global state, two copies of the same block on one page will fight over it. When in doubt, use context.
Building a Load More Posts Block
Now replace the example with something useful: a block that lists recent posts and loads the next page from the REST API when the visitor clicks a button, without reloading the page. The first page is rendered in PHP, so it works without JavaScript and is fully indexable.
Server Rendering with render.php
<?php
// wp-content/plugins/wsm-load-more/src/wsm-load-more/render.php
$per_page = isset( $attributes['perPage'] ) ? max( 1, (int) $attributes['perPage'] ) : 3;
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $per_page,
'ignore_sticky_posts' => true,
)
);
$posts = array_map(
function ( $post ) {
return array(
'id' => $post->ID,
'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES, get_bloginfo( 'charset' ) ),
'link' => get_permalink( $post ),
);
},
$query->posts
);
wp_interactivity_state(
'wsm/load-more',
array(
'restUrl' => esc_url_raw( rest_url( 'wp/v2/posts' ) ),
)
);
$context = array(
'posts' => $posts,
'page' => 1,
'perPage' => $per_page,
'totalPages' => (int) $query->max_num_pages,
'hasMore' => $query->max_num_pages > 1,
'isLoading' => false,
'hasError' => false,
);
?>
<div
<?php echo get_block_wrapper_attributes(); ?>
data-wp-interactive="wsm/load-more"
<?php echo wp_interactivity_data_wp_context( $context ); ?>
data-wp-class--is-loading="context.isLoading"
>
<ul class="wsm-load-more__list">
<template data-wp-each--post="context.posts" data-wp-each-key="context.post.id">
<li>
<a data-wp-bind--href="context.post.link" data-wp-text="context.post.title"></a>
</li>
</template>
</ul>
<p class="wsm-load-more__error" data-wp-bind--hidden="!context.hasError" role="alert">
<?php esc_html_e( 'Could not load more posts. Please try again.', 'wsm-load-more' ); ?>
</p>
<button
type="button"
data-wp-on--click="actions.loadMore"
data-wp-bind--hidden="!context.hasMore"
data-wp-bind--disabled="context.isLoading"
>
<?php esc_html_e( 'Load more', 'wsm-load-more' ); ?>
</button>
</div>
What is happening here:
- The first page comes from
WP_Query, and each post is reduced to the three fields the list needs. Titles are decoded becausedata-wp-textsets plain text. - The REST URL goes in global state, because it is the same for every instance of the block.
rest_url()returns the correct URL whether your site uses pretty permalinks or not. - Everything per-instance goes in context, so two copies of the block on one page each track their own page number.
data-wp-eachrenders oneliper post. The server processes it too, so the first page of links is in the HTML on load.data-wp-each-keygives each item a stable key so the runtime can update the list efficiently.- The button hides itself when there are no more pages and disables itself while a request is in flight.
The Store in view.js
// wp-content/plugins/wsm-load-more/src/wsm-load-more/view.js
import { store, getContext } from "@wordpress/interactivity";
const decodeEntities = (html) =>
new DOMParser().parseFromString(html, "text/html").documentElement
.textContent;
const { state } = store("wsm/load-more", {
actions: {
*loadMore() {
const context = getContext();
if (context.isLoading || !context.hasMore) {
return;
}
context.isLoading = true;
context.hasError = false;
try {
const url = new URL(state.restUrl);
url.searchParams.set("page", context.page + 1);
url.searchParams.set("per_page", context.perPage);
url.searchParams.set("_fields", "id,link,title");
const response = yield fetch(url);
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
const posts = yield response.json();
context.posts = [
...context.posts,
...posts.map((post) => ({
id: post.id,
link: post.link,
title: decodeEntities(post.title.rendered),
})),
];
context.page += 1;
context.hasMore = context.page < context.totalPages;
} catch (error) {
context.hasError = true;
} finally {
context.isLoading = false;
}
},
},
});
Two details matter:
- The action is a generator function (
*loadMore) and usesyieldinstead ofawait. Asynchronous actions in the Interactivity API should be generators so the runtime can restore the correct scope after each step. If you useasyncandawait,getContext()called after anawaitmay not refer to the right element. _fields=id,link,titlekeeps the REST response small. The REST API returnstitle.renderedwith HTML entities, so the action decodes it before passing it todata-wp-text.
Run npm run build, add the block to a page, and click Load more. The new links appear under the existing ones, the button disappears on the last page, and the browser's Network panel shows a single small JSON request per click. For more on the endpoint itself, see what the WordPress REST API is and how to use it.
A Simple Editor Preview
The editor does not run render.php through the Interactivity runtime, so keep edit.js simple. The easiest preview is ServerSideRender, or a static placeholder with a control for the perPage attribute:
// wp-content/plugins/wsm-load-more/src/wsm-load-more/edit.js
import { __ } from "@wordpress/i18n";
import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { PanelBody, RangeControl } from "@wordpress/components";
export default function Edit({ attributes, setAttributes }) {
return (
<>
<InspectorControls>
<PanelBody title={__("Settings", "wsm-load-more")}>
<RangeControl
label={__("Posts per page", "wsm-load-more")}
value={attributes.perPage}
onChange={(perPage) => setAttributes({ perPage })}
min={1}
max={12}
/>
</PanelBody>
</InspectorControls>
<p {...useBlockProps()}>
{__(
"Load More Posts: the list renders on the front end.",
"wsm-load-more",
)}
</p>
</>
);
}
Using Events That Need preventDefault
Actions run asynchronously by default so they do not block the main thread. That means calling event.preventDefault() or event.stopPropagation() inside a plain action can be too late. Since WordPress 6.8, wrap those actions in withSyncEvent:
// view.js
import { store, getContext, withSyncEvent } from "@wordpress/interactivity";
store("wsm/tabs", {
actions: {
selectTab: withSyncEvent((event) => {
event.preventDefault();
const context = getContext();
context.activeTab = event.target.dataset.tab;
}),
},
});
Only use withSyncEvent when you need synchronous access to the event. For everything else, a normal action is better for responsiveness.
Derived State and Callbacks
Derived state keeps your data minimal. Instead of storing buttonLabel and keeping it in sync by hand, compute it:
// view.js
import { store, getContext } from "@wordpress/interactivity";
store("wsm/accordion", {
state: {
get toggleLabel() {
return getContext().isOpen ? "Hide details" : "Show details";
},
},
actions: {
toggle() {
const context = getContext();
context.isOpen = !context.isOpen;
},
},
callbacks: {
focusPanel() {
const { isOpen } = getContext();
if (isOpen) {
// Runs every time isOpen changes, via data-wp-watch.
}
},
},
});
Derived state defined only in JavaScript is not known to the server, so the first render will not have it. For values that must be correct in the initial HTML, either store them in context or state from PHP, or define the derived value in PHP as well. wp_interactivity_state() accepts a closure as a state value for this purpose, and inside it you can read the current context with wp_interactivity_get_context().
Common Problems and Fixes
- Nothing happens on click. Check that the root element has
data-wp-interactivewith the exact namespace used instore(), thatblock.jsonhassupports.interactivity, and thatview.jsis loaded as a module throughviewScriptModule. - "Cannot use import statement outside a module." The build did not use
--experimental-modules, or the file is registered asviewScript. Fix both. - The initial HTML is wrong, then jumps after load. The value used by a directive only exists in JavaScript. Move it into context or state set in PHP so the server can render it.
- Two blocks on one page affect each other. Per-instance data is in global state. Move it to context.
getContext()returns the wrong values in an async action. Use a generator withyieldinstead ofasyncandawait.- HTML entities show in text.
data-wp-textsets text content. Decode entities in PHP withhtml_entity_decode()and in JavaScript before assigning. - Directives in a theme template are not processed. Server-side processing runs automatically for blocks that declare interactivity support. Markup outside such blocks still hydrates in the browser, but will not get server-rendered initial values.
WordPress Interactivity API FAQ
The Interactivity API became part of WordPress core in version 6.5. Later releases added features such as derived state in PHP, withSyncEvent, and faster block registration, so use a current version of WordPress for new projects.
No. Front-end behavior is written as plain JavaScript stores and HTML directives. React is still used in the block editor for the edit component, but visitors never download React for an interactive block.
Directives can technically be used in any markup on a page where the runtime is loaded, but the supported and recommended path is inside blocks that declare interactivity support, because that is where WordPress processes directives on the server and loads the view module automatically.
State is global for a namespace and shared by every block instance on the page. Context is local to an element and its children. Use state for shared configuration and values that must stay in sync everywhere, and context for anything specific to one instance of a block.
The runtime tracks which element and namespace an action belongs to. Generator functions let it restore that scope after each yield, so getContext and getElement keep returning the right values. Plain async functions can lose that scope after an await.
Yes. The initial HTML is rendered on the server and can be cached like any other page. State that changes per visitor should be loaded in the browser through an action or the REST API, not baked into cached HTML.
Conclusion
The Interactivity API gives WordPress blocks one consistent way to handle front-end behavior. You declare what should happen with directives in PHP, WordPress renders the correct initial HTML on the server, and a small shared runtime keeps the page in sync with state in the browser. The result is faster pages, less custom JavaScript, and interactive blocks that work together instead of competing.
Start with the official interactive template, keep per-instance data in context and shared configuration in state, use generators for async actions, and make sure every value that affects the first render is available in PHP. With those habits, the load more block in this article is a pattern you can reuse for tabs, filters, accordions, and anything else your blocks need to do in the browser.
Here are some useful references for going deeper on the Interactivity API:
- Block Editor Handbook: Interactivity API Reference — the official documentation for directives, stores, and server-side rendering.
- Block Editor Handbook: Quick start guide — scaffolding your first interactive block.
- WordPress Developer Resources: wp_interactivity_state() — function reference for setting global state from PHP.
- npm: @wordpress/create-block-interactive-template — the official template used in this guide.
- Make WordPress Core: Interactivity API posts — dev notes on new features in each release.


