Introduction
By Day 25 of WordPress development, you've likely built plenty of static blocks and wrestled with when to reach for JavaScript frameworks. The Interactivity API in WordPress Development & Plugins changes that calculus by letting you build interactive experiences with minimal overhead—no React bundle, no complex build setup, just markup-driven interactivity that works server-rendered by default. We'll walk through how to use declarative directives, share state across blocks, and understand why this approach matters for both performance and maintainability.
What is the Interactivity API?
The Interactivity API is WordPress's native solution for adding front-end reactivity to blocks without shipping a full framework. Introduced in WordPress 6.4 and refined since, it uses data attributes and simple directives in your block markup to wire up event handlers, manage component state, and re-render only what changed. Think of it as a lightweight alternative to React for the 80% of interactive features you actually build on client sites: modals, toggles, form validation, shopping cart updates, and filtering.
The API is built on top of Preact internally (a 3KB React-like library), but you don't need to know that. You write HTML with data attributes, define a store for shared state, and the Interactivity API handles reactivity behind the scenes. The result is smaller JavaScript payloads, faster time-to-interactive, and code that's often easier for other developers to follow.
Declarative Directives in Markup
The power of the Interactivity API lies in its declarative syntax. Instead of writing imperative JavaScript to query the DOM and attach listeners, you declare intent directly in your HTML using data attributes. Common directives include data-wp-bind, data-wp-on, data-wp-text, and data-wp-class.
Let's say you're building a product card block with a quantity selector. With the Interactivity API, your render method might look like this:
<?php
namespace MyPluginNamespace\Blocks;
function render_product_card( $attributes, $content ) {
$product_id = $attributes['productId'] ?? 0;
$initial_qty = $attributes['initialQuantity'] ?? 1;
wp_enqueue_script(
'product-card-interactivity',
plugin_dir_url( __FILE__ ) . 'product-card.js',
array( 'wp-interactivity' ),
filemtime( plugin_dir_path( __FILE__ ) . 'product-card.js' ),
true
);
return sprintf(
'<div data-wp-interactive=\'{"namespace":"myPlugin/productCard"}\' data-wp-context=\'{"productId":%d,"quantity":%d}\'>
<h3>%s</h3>
<p data-wp-text="state.price">$0.00</p>
<div>
<button data-wp-on--click="actions.decreaseQuantity" aria-label="Decrease quantity">−</button>
<span data-wp-text="context.quantity">%d</span>
<button data-wp-on--click="actions.increaseQuantity" aria-label="Increase quantity">+</button>
</div>
<button data-wp-on--click="actions.addToCart">Add to Cart</button>
</div>',
$product_id,
$initial_qty,
esc_html( get_the_title( $product_id ) ),
$initial_qty
);
}And in your JavaScript store definition:
// product-card.js
import { store } from '@wordpress/interactivity';
store( 'myPlugin/productCard', {
state: {
price: 29.99,
},
actions: {
increaseQuantity() {
const context = this.context;
if ( context.quantity < 10 ) {
context.quantity++;
}
},
decreaseQuantity() {
const context = this.context;
if ( context.quantity > 1 ) {
context.quantity--;
}
},
addToCart() {
const { productId, quantity } = this.context;
// Send to cart endpoint
console.log( `Adding ${quantity} of product ${productId}` );
},
},
} );Notice what happened: no DOM queries, no addEventListener loops, no manual update logic. The directives handle it. data-wp-on--click binds the click event to an action. data-wp-text binds a text node to state. data-wp-context provides local block context. When state or context changes, only the affected DOM nodes re-render.
Shared State Between Blocks
One of the trickier challenges in custom block development is sharing state across multiple blocks on a page. With the Interactivity API, you can use a global store that any block in the same namespace can read and modify.
Imagine a scenario where you have a product filter block and a product grid block. The filter block lets users select categories, and the grid should update to show matching products. Both blocks tap into the same store:
// store.js - shared by both blocks
import { store } from '@wordpress/interactivity';
store( 'myPlugin/products', {
state: {
selectedCategory: 'all',
filteredProducts: [],
loading: false,
},
actions: {
async selectCategory( category ) {
this.state.selectedCategory = category;
this.state.loading = true;
const response = await fetch( '/wp-json/myPlugin/v1/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( { category } ),
} );
const products = await response.json();
this.state.filteredProducts = products;
this.state.loading = false;
},
},
} );The filter block renders:
<div data-wp-interactive='{"namespace":"myPlugin/products"}'>
<button data-wp-on--click="actions.selectCategory" data-wp-bind--data-category="all"
data-wp-class--active="state.selectedCategory === 'all'">
All Products
</button>
<button data-wp-on--click="actions.selectCategory" data-wp-bind--data-category="electronics"
data-wp-class--active="state.selectedCategory === 'electronics'">
Electronics
</button>
</div>And the grid block listens to the same state:
<div data-wp-interactive='{"namespace":"myPlugin/products"}'>
<div data-wp-class--loading="state.loading">
<!-- template render of state.filteredProducts -->
</div>
</div>Both blocks are aware of the global state without any custom postMessage or custom events. This pattern works because the store is namespaced but accessible to all blocks using that namespace.
Lighter Than Shipping React
The Interactivity API uses Preact internally, which is only 3KB gzipped compared to React's 42KB. But the real savings come from not shipping your own React dependencies, polyfills, and build tooling.
Consider a traditional WordPress plugin that bundles React for a single block:
- React library: ~42KB gzipped
- ReactDOM: ~40KB gzipped
- Your component code: ~10KB gzipped
- Total initial JavaScript payload: ~92KB, plus parsing and execution time
With the Interactivity API:
- Store definition and actions: ~3KB gzipped
- Markup handles rendering
- Total: ~3KB, no additional framework overhead
On a mobile connection, that difference matters. On a homepage with 10 interactive blocks, it's transformational. You're not just optimizing a single component—you're avoiding framework duplication across your entire site. WordPress handles the Interactivity API runtime; your code just defines behavior.
This approach also means better browser compatibility and fewer JavaScript errors from conflicting polyfills or version mismatches.
Server-Rendered First
A key philosophy of the Interactivity API is server-render first, hydrate second. Your block always outputs valid HTML from the server. JavaScript enhances that HTML with interactivity—it doesn't replace it.
This has profound implications for accessibility, SEO, and resilience. If JavaScript fails to load or execute, users still see content and can interact via server-side forms. Search engines see the real HTML structure, not an empty div waiting for client-side rendering.
When you render a block:
function render_accordion( $attributes, $content ) {
return sprintf(
'<div class="accordion" data-wp-interactive=\'{"namespace":"myPlugin/accordion"}\'>
<div class="accordion-item">
<button data-wp-on--click="actions.toggle" data-wp-bind--aria-expanded="state.isOpen">
Section 1
</button>
<div data-wp-class--hidden="!state.isOpen">
Content for section 1
</div>
</div>
</div>'
);
}The user sees the accordion with all sections expanded on page load. When JavaScript loads, Preact hydrates the component, attaches event listeners, and state.isOpen starts controlling visibility. If JavaScript never arrives, the accordion still works—maybe not collapsible, but visible and navigable.
Compare this to a Single Page App approach where a JavaScript bundle must load, parse, execute, fetch data, and render before the user sees anything. The Interactivity API inverts that waterfall: content first, enhancement second.
Common Mistakes
Overcomplicating state structure: Developers often mirror their entire component tree in the store. Start simple. Put only data that multiple blocks or callbacks need to share in state. Use context for local block data.
Forgetting context scope: Context is local to a data-wp-context element and its children. If you set context in one block and expect another block to read it, you'll be confused. Use the global store instead.
Mixing Interactivity API with inline event handlers: Don't add onclick attributes to elements controlled by data-wp-on. Let the directives win. Manual event handling will conflict with the API's virtual DOM diffing.
Assuming all JavaScript bundlers work: The Interactivity API expects ES modules and modern JavaScript. If you're using an older WordPress site with PHP 5.6 or very old browser requirements, you may hit compatibility issues. Always test on your target environment.
Not handling async operations gracefully: When you fetch data in an action, the UI doesn't automatically freeze or show a loading state. Set a loading flag explicitly, then update it when the fetch completes. Users need visual feedback.
Key Takeaways
- The Interactivity API lets you add client-side reactivity to blocks using data attributes and a simple JavaScript store, without shipping React or a build pipeline.
- Use directives like
data-wp-on,data-wp-bind, anddata-wp-textto declare behavior in your markup, keeping your PHP clean and your JavaScript focused on logic. - Namespace your store and leverage global state to share data between multiple blocks on the same page—no postMessage, no custom event bus needed.
- Server-render your blocks with valid HTML first; Preact hydrates on the client and adds interactivity without replacing the initial HTML. This improves accessibility, SEO, and resilience.
- The performance win over shipping React is significant: 3KB of directives versus 82KB of framework, plus faster time-to-interactive and lower parse/compile overhead.
- Start with simple use cases—toggles, filters, form validation—before attempting complex state machines. The API shines for these patterns.






Comments
Loading comments…