The WordPress Interactivity API is the first officially-blessed way to write frontend interactivity that plays nicely with block themes — directives in your markup, a small reactive store in JS, server-rendered HTML underneath. The docs mostly show toy counters. Here’s a real use case: the filterable portfolio grid on my projects page, which filters by project type instantly with animated re-layout, works without JavaScript, and ships with no build step.
Why a custom block at all?
Rule I hold myself to: core blocks and patterns first, custom blocks only when core genuinely can’t express the behaviour. A Query Loop can render a grid of projects — but it can’t re-filter on the client without a page reload. That interaction is the entire point of the section, so it earned the theme’s single custom block.
The server side: render.php owns the markup
The block is dynamic. PHP queries the project post type, renders every card, and stamps each one with a context object the client can read:
<div
data-wp-interactive="minhazi/projects-grid"
<?php echo wp_kses_data( wp_interactivity_data_wp_context( [ 'activeFilter' => 'all' ] ) ); ?>
>
<button data-wp-on--click="actions.setFilter" data-filter="all">All</button>
...
<div class="projitem"
data-wp-context='{"cat":"woocommerce"}'
data-wp-class--is-hidden="state.isHidden">
...card markup...
</div>
</div>Because the cards are server-rendered, the grid is complete HTML before any JS runs: crawlers see everything, and a JS failure degrades to a working (unfiltered) grid rather than an empty div. That’s the quiet superpower of this API versus bolting on a React widget.
The client side: a store, not a framework
import { store, getContext } from '@wordpress/interactivity';
store( 'minhazi/projects-grid', {
state: {
get isHidden() {
const { cat } = getContext();
const { activeFilter } = getContext(); // inherited from the root context
return activeFilter !== 'all' && activeFilter !== cat;
},
},
actions: {
setFilter( event ) {
getContext().activeFilter = event.target.dataset.filter;
},
},
} );That’s essentially the whole behaviour. The data-wp-class--is-hidden directive reacts to state, CSS transitions handle the animated re-layout, and there is no diffing, no hydration ceremony, no bundle.
No build step — the details that make it work
- the view module is registered with
wp_register_script_module()naming@wordpress/interactivityas an explicit dependency — that dependency is what makes WordPress print the import map for the bare specifier. (Gotcha:viewScriptModulein block.json alone is not enough without a build — noview.asset.phpmeans no declared dependency, no import map, and a console error.) - The editor script is registered by handle in
functions.phpwith explicitwp-blocks/wp-elementdeps, because there’s no build to emit an asset manifest. - The editor preview uses
ServerSideRender, so what you see in the Site Editor is the real render.php output.
When to reach for it
Filtering, toggles, tabs, lightboxes, small stateful UI attached to server-rendered content — perfect fit. Full application state, routing, complex data fetching — wrong tool, use a real framework. The skill is knowing the line. If you’re weighing it for a project, I’m easy to reach.

Leave a Reply