The WordPress Interactivity API in Practice: A Filterable Grid with No Build Step

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/interactivity as an explicit dependency — that dependency is what makes WordPress print the import map for the bare specifier. (Gotcha: viewScriptModule in block.json alone is not enough without a build — no view.asset.php means no declared dependency, no import map, and a console error.)
  • The editor script is registered by handle in functions.php with explicit wp-blocks/wp-element deps, 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

Your email address will not be published. Required fields are marked *