WordPress Interactivity API tutorial: build a live filter block
Build a live post filter block with the WordPress Interactivity API: directives, context, derived state, watch() from WordPress 7.0 and the usual pitfalls.
Updated 7 min read

In this article
The Interactivity API is WordPress's built-in way to add front-end behaviour to blocks: you write HTML with data-wp-* directives in PHP, keep the logic in a small JavaScript store, and WordPress renders the first state on the server and hydrates it in the browser. It has been part of core since WordPress 6.5, and core blocks such as Navigation, Image (lightbox) and Query use it. If you are still shipping jQuery or a full React bundle to toggle a menu or filter a list, this is the lighter, standard option.
In this tutorial you will build a "Post Filter" block: a list of recent posts with a search box that hides non-matching titles as the visitor types. Along the way you will use the main directives, global state, local context, derived state and the watch() function added in WordPress 7.0.
How the Interactivity API works
Three pieces work together:
- Directives are HTML attributes such as
data-wp-on--clickordata-wp-bind--hidden. They connect markup to your store. - The store is a JavaScript module that holds state (global, shared by every block using the namespace), actions (things that happen on events) and callbacks (side effects).
- Context is local state set with
data-wp-contexton an element and inherited by its children. Each copy of a block on a page gets its own context.
Because directives are processed on the server first, visitors see the correct HTML before any JavaScript runs, which helps both search engines and Core Web Vitals.
| Directive | What it does | Example |
|---|---|---|
data-wp-interactive | Turns on interactivity and sets the store namespace | data-wp-interactive="acme/post-filter" |
data-wp-context | Local JSON state for an element and its children | data-wp-context='{"query":""}' |
data-wp-on--[event] | Runs an action on a DOM event | data-wp-on--input="actions.updateQuery" |
data-wp-bind--[attr] | Sets an attribute from state or context | data-wp-bind--hidden="state.isHidden" |
data-wp-text | Sets the element's text | data-wp-text="state.label" |
data-wp-class--[name] | Toggles a CSS class | data-wp-class--is-active="context.isOpen" |
data-wp-watch | Runs a callback when the values it reads change | data-wp-watch="callbacks.logQuery" |
data-wp-each | Renders a list from an array using a <template> | data-wp-each="context.items" |
Step 1: Scaffold an interactive block
The official quick start uses @wordpress/create-block with the interactive template. Run it inside wp-content/plugins of a local site:
npx @wordpress/create-block@latest post-filter \
--namespace=acme \
--template @wordpress/create-block-interactive-template
cd post-filter
npm startNo local WordPress yet? From the plugin folder, npx @wp-playground/cli server --auto-mount starts a WordPress Playground site with the plugin mounted.
The template adds @wordpress/interactivity as a dependency and runs wp-scripts with the --experimental-modules flag, because the front-end code is loaded as a JavaScript module. Open src/post-filter/block.json and check three keys:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "acme/post-filter",
"title": "Post Filter",
"category": "widgets",
"textdomain": "post-filter",
"supports": {
"interactivity": true
},
"render": "file:./render.php",
"viewScriptModule": "file:./view.js"
}supports.interactivity tells WordPress the block uses directives, render makes it a dynamic block rendered by PHP, and viewScriptModule loads view.js as a module only on pages that contain the block.
Step 2: Render the markup and directives in PHP
Replace render.php with the following. It queries the 12 latest posts and gives each list item its own context containing the plain-text title, while the wrapper holds the shared query value.
<?php
/**
* Front-end markup for the acme/post-filter block.
*
* @var array $attributes Block attributes.
* @var string $content Block content.
* @var WP_Block $block Block instance.
*/
$posts = new WP_Query(
array(
'posts_per_page' => 12,
'no_found_rows' => true,
'ignore_sticky_posts' => true,
)
);
$input_id = wp_unique_id( 'acme-post-filter-' );
?>
<div
<?php echo get_block_wrapper_attributes(); ?>
data-wp-interactive="acme/post-filter"
<?php echo wp_interactivity_data_wp_context( array( 'query' => '' ) ); ?>
>
<label for="<?php echo esc_attr( $input_id ); ?>">
<?php esc_html_e( 'Filter posts by title', 'post-filter' ); ?>
</label>
<input
id="<?php echo esc_attr( $input_id ); ?>"
type="search"
data-wp-bind--value="context.query"
data-wp-on--input="actions.updateQuery"
/>
<button type="button" data-wp-on--click="actions.clear">
<?php esc_html_e( 'Clear', 'post-filter' ); ?>
</button>
<ul>
<?php while ( $posts->have_posts() ) : ?>
<?php
$posts->the_post();
$plain_title = html_entity_decode( wp_strip_all_tags( get_the_title() ), ENT_QUOTES, get_bloginfo( 'charset' ) );
?>
<li
<?php echo wp_interactivity_data_wp_context( array( 'title' => $plain_title ) ); ?>
data-wp-bind--hidden="state.isHidden"
>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</li>
<?php endwhile; ?>
</ul>
<?php wp_reset_postdata(); ?>
</div>Use wp_interactivity_data_wp_context() rather than hand-writing JSON: it encodes the array and escapes it for an HTML attribute. Every list item inherits query from the wrapper and adds its own title, because nested contexts merge with their parents.

Step 3: Write the store in view.js
Replace view.js with this store. The namespace must match data-wp-interactive exactly.
import { store, getContext, getElement, watch } from '@wordpress/interactivity';
const { state } = store( 'acme/post-filter', {
state: {
lastQuery: '',
// Derived state: computed for each list item from its context.
get isHidden() {
const { query, title } = getContext();
return query !== '' && ! title.toLowerCase().includes( query );
},
},
actions: {
updateQuery() {
const context = getContext();
const { ref } = getElement();
context.query = ref.value.trim().toLowerCase();
state.lastQuery = context.query;
},
clear() {
getContext().query = '';
},
},
} );
// WordPress 7.0+: react to store changes outside the DOM.
watch( () => {
if ( state.lastQuery ) {
window.console.debug( 'Filtering posts for:', state.lastQuery );
}
} );How the pieces fit:
state.isHiddenis a getter, so it is derived state. When an<li>evaluatesdata-wp-bind--hidden,getContext()returns that item's merged context, so each item decides for itself.actions.updateQueryreads the input throughgetElement().ref. Actions run asynchronously by default; you only needwithSyncEvent()when you callevent.preventDefault()or stop propagation.watch()was added to@wordpress/interactivityin WordPress 7.0. It runs immediately, re-runs whenever a value it read changes, and returns anunwatchfunction. Here it logs the most recent search, kept in global state, which is a handy hook for analytics or syncing the query to the URL. If your plugin must support WordPress 6.9 or earlier, use adata-wp-watchcallback instead.
Rebuild (or let npm start rebuild), insert the block, publish and type in the box on the front end. Titles that do not match disappear without a request to the server.
Step 4: Share data from the server with global state
When several blocks need the same values, or when strings need translating, define global state in PHP with wp_interactivity_state(). It is merged with the state defined in view.js:
wp_interactivity_state(
'acme/post-filter',
array(
'noResultsText' => __( 'No posts match your search.', 'post-filter' ),
)
);For values that are configuration rather than reactive state, such as whether the visitor is logged in, use wp_interactivity_config() in PHP and getConfig() in JavaScript.

Async actions and client-side navigation
Asynchronous actions, such as fetching results from the REST API, must be written as generator functions using yield instead of async/await. Generators let the API restore the correct context scope when your code resumes:
import { store, getContext } from '@wordpress/interactivity';
store( 'acme/post-filter', {
actions: {
*loadCount() {
const context = getContext();
const response = yield fetch( '/wp-json/wp/v2/posts?per_page=1' );
context.total = Number( response.headers.get( 'X-WP-Total' ) );
},
},
} );For page transitions without full reloads, the separate @wordpress/interactivity-router package provides actions.navigate(). Since WordPress 7.0, state.url in the core/router store is filled in on the server, so you can combine it with watch() to track virtual page views. Reading state.navigation from that store is deprecated in 7.0.
Gotchas
- Namespace mismatch. If
data-wp-interactiveandstore()use different names, nothing happens and there is no error on the page. Check the browser console withSCRIPT_DEBUGenabled. - Server and client must agree. The server cannot run your JavaScript getters, so the first render uses the state and context you print in PHP. Make the initial HTML correct on its own: here, every item is visible because the query starts empty.
- Do not inject untrusted HTML. Prefer
data-wp-text. Thewp-htmldirective only renders values wrapped withasDangerousHTML(), which performs no sanitising at all. - Keep context small. Context is printed as JSON in the HTML. Put IDs and flags in it, not whole post objects.
- Accessibility is still your job. Label inputs, keep buttons as
<button>elements and bindaria-expandedwhen you toggle content.
What to do next
- Add a polite live region that announces how many posts match, driven by a derived value.
- Move the post query into a block attribute so editors can choose the post type and number of items.
- Read the "Directives and Store" reference for
data-wp-each,getServerContext()and private stores. - Test the block with JavaScript disabled to confirm the server-rendered list still works.
Frequently asked questions
When was the Interactivity API added to WordPress?
It became a public core API in WordPress 6.5 "Regina", released in April 2024. WordPress 7.0 added the
watch()function and server-populatedstate.urlin the router store.Do I need React to use the Interactivity API?
No. You write HTML directives in PHP and a small store in JavaScript. The editor side of a block can still use React components, but the front end does not need a React bundle.
What is the difference between state and context?
State is global to a namespace and shared by every block that uses it. Context is local: set with
data-wp-contexton an element and inherited by its children, so each copy of a block on a page has its own values.Why use generators instead of async/await in actions?
The API needs to know when an action pauses and resumes so it can restore the right context scope. Generator functions with
yieldmake that possible;async/awaitdoes not.When do I need withSyncEvent()?
Only when an action needs synchronous access to the event, for example to call
event.preventDefault()orevent.stopPropagation(). Since WordPress 6.8, actions that do this withoutwithSyncEvent()trigger a deprecation warning.Can I use the Interactivity API in a classic theme?
Yes. Directives work in any server-rendered HTML that loads a store module, and blocks that use it render in classic themes too. Block themes simply make it easier to place interactive blocks in templates.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Interactivity API quick start guide — Block Editor Handbook, 2024-04-29
- Interactivity API: Directives and Store — Block Editor Handbook
- Changes to the Interactivity API in WordPress 7.0 — Make WordPress Core, 2026-02-23
- create-block-interactive-template — Gutenberg on GitHub
- WordPress 6.5 "Regina" — WordPress.org News, 2024-04-02
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Resource guideWordPress
WordPress security hardening checklist for 2026 (WordPress 7.1)
A practical WordPress 7.1 hardening checklist based on the official handbook: updates, two-step logins, wp-config.php constants, file permissions, database privileges, firewalls, WP-CLI checksum checks and tested backups.
By Cloudsoft Zone

TutorialWordPress
How to update WordPress safely with WP-CLI: backup, update, verify, roll back
A repeatable WordPress update routine with WP-CLI: check pending updates, export the database and archive files, update core, plugins, themes and translations in order, verify checksums, and restore if something breaks.
By Cloudsoft Zone

Troubleshooting guideCodeIgniter
CodeIgniter 4 validation not working? 9 causes and fixes
Fix CodeIgniter 4 validation problems fast: wrong input source, lost errors after redirect, permit_empty, strict JSON rules, is_unique placeholders and more.
By Cloudsoft Zone
