Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 11 / 18 40 MIN

Dynamic blocks and the Interactivity API

The Interactivity API: Directives and State

Directives are attributes that WordPress resolves on the server and Preact subscribes to on the client, with one store of state, actions and callbacks behind both.

You want a block whose button opens a panel. The obvious answer is a script that queries the button, adds a click listener, and toggles a class, and the obvious answer is also where most block themes go wrong: a second event listener after a client-side navigation, markup that only works when the script has run, and no way for the server to render the panel's open state.

The Interactivity API replaces that script with attributes. You write data-wp-on--click on the button and data-wp-class--is-open on the panel, and WordPress does two separate things with them. PHP evaluates what it can while it renders, so the served HTML already carries the open state, and the browser takes over the same attributes, subscribes them to state, and re-renders the region when that state changes. Understanding which half runs where is the difference between using the API and fighting it.

The seven directives, and the two that are plumbing

WordPress 7.0.7 registers exactly these directives on the client, read from the directive() calls in wp-includes/js/dist/script-modules/interactivity/index.js: bind, class, context, each, each-child, html, ignore, init, on, style, text and watch, plus the variants on-document, on-window, on-async, on-async-document and on-async-window. On the server, PHP handles a smaller list: data-wp-interactive, data-wp-context, data-wp-bind, data-wp-class, data-wp-style, data-wp-text, data-wp-each and data-wp-router-region. The difference is the point of the whole design.

AttributeWhat it controlsServerClient
data-wp-interactiveThe store namespace the subtree belongs toReadRead
data-wp-contextA JSON object local to this element and its descendantsStackStack
data-wp-textText content of the elementResolvedSubscribed
data-wp-bind--{attr}One HTML attributeResolvedSubscribed
data-wp-class--{name}Presence of one classResolvedSubscribed
data-wp-style--{prop}One inline style propertyResolvedSubscribed
data-wp-eachExpands a <template> once per item in a listExpandedRe-rendered
data-wp-on--{event}An event handlerLeft in placeAttached
data-wp-watchA callback that runs whenever the state it reads changesLeft in placeSubscribed
data-wp-initA callback that runs once when the element is createdLeft in placeSubscribed

The two plumbing directives are data-wp-each-child and data-wp-ignore. You do not write either. data-wp-each-child is set by the server on the top-level elements it expanded from a template, and it exists so that processing skips them the second time around: a markup fragment carrying it is jumped to its closing tag rather than evaluated again. data-wp-ignore stops descent into a subtree, which is what a third-party widget embedded in your interactive block needs.

What the server actually evaluates

PHP has its own small evaluator, and it resolves one reference path against a store of state and context. A path is property access with dots, an optional leading ! to negate, and the special property length. It is not JavaScript. Feed it state.isOpen ? 'block' : 'none' and the value is null, so data-wp-style--display is left out of the markup and the client applies it after hydration — a difference you can see in the served HTML and one of the few cases where writing two directives instead of one expression pays for itself.

<span data-wp-text="state.label">Open</span>
<span data-wp-text="state.double">4</span>
<button aria-expanded="false" data-wp-bind--aria-expanded="context.isOpen">Toggle</button>
<div data-wp-style--display="state.isOpen ? 'block' : 'none'"></div>

Given a store whose label is Open and a derived double of 4, the served markup contains the resolved values for the first two, the string false for the bound attribute — booleans are stringified exactly as Preact will stringify them later, so aria- and data- attributes never become the text true in the wrong case — and no style attribute at all, because the expression could not be evaluated.

Directive processing is not something you call. It happens inside WP_Block::render() for a block whose block.json declares "supports": { "interactivity": true }, after the inner blocks are rendered and after the render_block filters have run. A block without that support can echo data-wp-text all day and the server will never touch it, which is a genuinely confusing way to learn that the flag is required and not merely conventional.

One store, three kinds of member

Directive values are references into a store, and store() is how you define one. It takes a namespace, which must be the string passed to data-wp-interactive, and an object whose members are of three kinds. state is data, including derived state written as getters. actions are functions that change state in response to something a user did. callbacks are functions that run as a consequence of state changing.

import { store, getContext, getConfig } from '@wordpress/interactivity';

const { state } = store( 'm4lab/counter', {
    state: {
        get atLimit() {
            return state.count >= 5;
        },
    },
    actions: {
        increment() {
            const context = getContext();
            state.count = Math.min( state.count + context.step, 5 );
        },
    },
    callbacks: {
        report() {
            console.log( 'count is', state.count );
        },
    },
} );

The split decides how a directive behaves. A directive that names a path under state is subscribed: when that value changes, every directive in the region that read it is applied again. Actions and callbacks are named from directives like data-wp-on--click="actions.increment" and data-wp-watch="callbacks.report", and they run in the region's scope, which is why getContext() inside one of them returns the context of the element that triggered it rather than of the store.

Getting the scope of a store wrong is the most common failure. state inside an action is not a global; it is the variable bound by const { state } = store( … ) at module scope. Write state.count = 1 in a file that never destructured it and the browser reports ReferenceError: state is not defined in the console, the directive does nothing, and the block looks inert rather than broken. This is checked everywhere: running typeof state in a browser with WordPress 7.0.7 loaded prints undefined.

data-wp-context is per element and it is inherited

A store's state is shared by every instance of the block on the page. Context is the opposite: it is attached to one element and read by that element and its descendants, which is how two instances of the same block keep independent values. Set it in PHP with the helper that exists for the purpose, and it survives quotation marks and angle brackets in the value.

<div <?php echo get_block_wrapper_attributes(); ?>
    data-wp-interactive="mytheme/tabs"
    <?php echo wp_interactivity_data_wp_context( array( 'activeTab' => 0 ) ); ?>>

On the server the context is a stack. Entering an element with data-wp-context pushes a merged copy — array_replace_recursive() of the parent's context and the new one — and leaving it pops the stack, so a nested element sees its own keys plus everything inherited. Two consequences follow. A descendant that repeats a key shadows it for its own subtree only, and wp_interactivity_get_context() called during processing returns the innermost context for the named namespace, which is how a derived state closure can be generic across instances.

The same keys behave the same way on the client, and that is why the server can render the open state of the third instance of an accordion without knowing anything about the other two. The core accordion block in wp-includes/blocks/accordion-item.php is a good file to read for the pattern: it sets data-wp-context with a unique id and the item's default, registers state.isOpen as a closure that reads wp_interactivity_get_context()['openByDefault'], then sets data-wp-class--is-open="state.isOpen" and data-wp-bind--aria-expanded="state.isOpen" on the toggle.

Server state is not client state, and the difference is visible

The state you set on the server is serialised into the page and becomes the client store's starting value. It is not merged over the client's own defaults, it replaces nothing, and it is only consulted for properties the client store does not define itself. So a store that declares count: 0 in JavaScript and a render.php that sets count to 3 produces a visible flip: the served HTML says 3, and the moment the region hydrates it says 0. Remove the default from the JavaScript store and the server value survives, which is the correct shape for anything the server computed.

Two functions make that observable. getServerState( 'mytheme/store' ) returns what the server sent, unchanged, for as long as the page lives; store( 'mytheme/store' ).state returns the live value. Compare them in the console when a hydrated block disagrees with its own HTML, and remember that the region is re-rendered from client state on hydration rather than patched into the served DOM.

wp_interactivity_config() is the companion for values that must never be written to, and it is not a store member at all. Call it from PHP with a namespace and an array, read it on the client with getConfig( 'mytheme/store' ), and it arrives in the page as JSON in the same block of data as the state. A nonce, a REST base, a translated string, an allowed value list and a price format all belong there, because configuration is read and never subscribed, and mixing it into state means a directive can accidentally depend on it.

Lab 45 minutes

Build a block with "supports": { "interactivity": true }, a render.php and a viewScriptModule. Render a list of three tickets from data-wp-each, with each row showing its own name through context.item.name and a "Select" button per row.

Put a selected key in the state, set it to the first ticket's id on the server, and mark the selected row with data-wp-class--is-selected. Then write an action for the button that sets state.selected from the row's context, and a data-wp-watch that writes the selected name into a live region.

Dump the served HTML with curl and write down which directives the server resolved and which it left alone. Then load the page and check three things in the console: what getServerState() returns for your namespace, which of your store defaults the server value overrode, and whether the data-wp-class--is-selected class was present before any JavaScript ran.

Finally, add wp_interactivity_config() with a nonce and read it in the action, and explain why it does not belong in state.

Show the solution and the reasoning

The server resolves data-wp-text inside the template for every item, and it appends the expanded rows after the template's closing tag with data-wp-each-child set on each of them. What it leaves alone is data-wp-on--click and data-wp-watch. The class directive is the interesting one: if the state value the directive reads is a plain path under state, the class is in the served HTML. If you wrote state.selected === context.item.id, no class is written, because the evaluator does not do equality.

<ul class="wp-block-mytheme-tickets" data-wp-interactive="mytheme/tickets">
    <template data-wp-each="state.tickets">
        <li data-wp-text="context.item.name"></li>
    </template>
    <li data-wp-each-child="mytheme/tickets::state.tickets"
        data-wp-text="context.item.name">Front row</li>
</ul>

Per-row selection is the reason the action needs getContext() rather than a loop variable. Each expanded row carries the context the server pushed for it, and the client reproduces that stack, so getContext() inside the click handler returns the item that was clicked. The alternative — an index in the state and a comparison in every directive — needs a derived getter to be evaluatable on both sides.

The check that matters is the third one. Whatever the server resolved is in the HTML a crawler and a JavaScript-disabled browser see, and everything else appears only after hydration. When you compare getServerState() with store( … ).state and they differ, the client store declared its own default for that key; when they match and the rendered value still flipped, the key is derived on one side and stored on the other, which is the harder bug of the two.

The nonce belongs in configuration because writing it into state would make it part of a reactive graph that re-renders directives. It also must not be a store default: a nonce generated in JavaScript is not a nonce. Configuration is the API for values that only the server can know and that nothing should ever subscribe to, which is exactly the set of things that go wrong when they are treated as state.

Progress

Mark this lesson done and it stays marked in this browser.