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.
| Attribute | What it controls | Server | Client |
|---|---|---|---|
data-wp-interactive | The store namespace the subtree belongs to | Read | Read |
data-wp-context | A JSON object local to this element and its descendants | Stack | Stack |
data-wp-text | Text content of the element | Resolved | Subscribed |
data-wp-bind--{attr} | One HTML attribute | Resolved | Subscribed |
data-wp-class--{name} | Presence of one class | Resolved | Subscribed |
data-wp-style--{prop} | One inline style property | Resolved | Subscribed |
data-wp-each | Expands a <template> once per item in a list | Expanded | Re-rendered |
data-wp-on--{event} | An event handler | Left in place | Attached |
data-wp-watch | A callback that runs whenever the state it reads changes | Left in place | Subscribed |
data-wp-init | A callback that runs once when the element is created | Left in place | Subscribed |
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.
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.