Type something to search...
How to Add Interactivity to Blocks with the WordPress Interactivity API?

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:

  1. Directives. HTML attributes such as data-wp-on--click and data-wp-bind--hidden that connect markup to behavior. You write them in PHP, in your block's render output.
  2. Stores. A JavaScript module per namespace that holds state, actions, and callbacks. Directives reference values in the store by path, like actions.toggle or state.isOpen.
  3. The runtime. The @wordpress/interactivity script 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 hidden attribute. 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.
ApproachInitial HTML correct without JSShared across blocksTypical size per block
jQuery snippet in viewScriptUsually notNoSmall, plus jQuery
React app mounted in a blockNo, renders after loadNoLarge
Interactivity APIYes, directives run in PHPYesVery 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:

FileRuns whereJob
block.jsonBuild and PHPDeclares interactivity support and the view script module
render.phpServerOutputs HTML with directives, sets initial state and context
view.jsBrowserDefines the store: state getters, actions, and callbacks

The request flow looks like this:

  1. WordPress renders the block by including render.php.
  2. Your PHP calls wp_interactivity_state() to define initial global state and prints the markup with data-wp-* attributes.
  3. Because the block declares supports.interactivity, WordPress processes the directives on the server and outputs correct initial HTML.
  4. The page loads view.js as a script module, which calls store() to register actions and derived state.
  5. 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: true tells WordPress to process directives in this block's output on the server.
  • viewScriptModule loads view.js as a script module on the front end, only on pages where the block appears. Use viewScriptModule, not viewScript, because the store must import the runtime as a module.
  • render makes 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 escaped data-wp-context attribute 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 state
  • actions: functions triggered by events, such as clicks
  • callbacks: functions run by directives such as data-wp-watch and data-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 !.

DirectiveWhat it doesExample
data-wp-interactiveActivates the API and sets the store namespacedata-wp-interactive="wsm/load-more"
data-wp-contextDefines local state for this element and its childrenPrinted with wp_interactivity_data_wp_context()
data-wp-on--{event}Runs an action on a DOM eventdata-wp-on--click="actions.loadMore"
data-wp-bind--{attr}Binds an HTML attribute to a valuedata-wp-bind--hidden="!context.hasMore"
data-wp-class--{name}Adds or removes a classdata-wp-class--is-loading="context.isLoading"
data-wp-style--{prop}Sets an inline style propertydata-wp-style--width="state.progress"
data-wp-textSets the element's text contentdata-wp-text="context.post.title"
data-wp-each--{item}Renders a list from an array, inside a template tagdata-wp-each--post="context.posts"
data-wp-watchRuns a callback whenever values it reads changedata-wp-watch="callbacks.logState"
data-wp-initRuns a callback once when the element is createddata-wp-init="callbacks.setup"

A few rules save debugging time:

  • The namespace is inherited. Everything inside an element with data-wp-interactive uses that store unless it declares its own.
  • Context is inherited and merged. Child elements see their parents' context, and a nested data-wp-context adds or overrides keys.
  • data-wp-text sets 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 state that 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 because data-wp-text sets 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-each renders one li per post. The server processes it too, so the first page of links is in the HTML on load. data-wp-each-key gives 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 uses yield instead of await. Asynchronous actions in the Interactivity API should be generators so the runtime can restore the correct scope after each step. If you use async and await, getContext() called after an await may not refer to the right element.
  • _fields=id,link,title keeps the REST response small. The REST API returns title.rendered with HTML entities, so the action decodes it before passing it to data-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-interactive with the exact namespace used in store(), that block.json has supports.interactivity, and that view.js is loaded as a module through viewScriptModule.
  • "Cannot use import statement outside a module." The build did not use --experimental-modules, or the file is registered as viewScript. 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 with yield instead of async and await.
  • HTML entities show in text. data-wp-text sets text content. Decode entities in PHP with html_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:

  1. Block Editor Handbook: Interactivity API Reference — the official documentation for directives, stores, and server-side rendering.
  2. Block Editor Handbook: Quick start guide — scaffolding your first interactive block.
  3. WordPress Developer Resources: wp_interactivity_state() — function reference for setting global state from PHP.
  4. npm: @wordpress/create-block-interactive-template — the official template used in this guide.
  5. Make WordPress Core: Interactivity API posts — dev notes on new features in each release.
Tags :
Share :

Related Posts

WordPress optimization with specific recommended approach

WordPress optimization with specific recommended approach

Whether you run a high traffic WordPress installation or a small blog on a low cost shared host, you should optimize WordPress and your server to run

Continue Reading
Creating and Customizing WordPress Child Themes

Creating and Customizing WordPress Child Themes

Creating a child theme in WordPress is a best practice for making modifications to a theme. By using a child theme, you can update the parent theme w

Continue Reading
Understanding the Distinction Categories vs. Tags in WordPress

Understanding the Distinction Categories vs. Tags in WordPress

WordPress, a powerful content management system, offers a plethora of features to organize content effectively. Among these features, categories and

Continue Reading