Must use when building any kind of frontend interactivity in WordPress — dynamic or static blocks, classic themes, anything using data-wp-* directives, the @wordpress/interactivity package, wp_interactivity_state/wp_interactivity_data_wp_context/wp_interactivity_config helpers, the interactivity-router, or whenever a task involves making server-rendered WordPress markup interactive (toggles, menus, counters, filters, lightboxes, client-side navigation, etc.). Use this even if the user does not mention 'Interactivity API' by name.
Must use when building any kind of frontend interactivity in WordPress — dynamic or static blocks, classic themes, anything using data-wp-* directives, the @wordpress/interactivity package, wp_interactivity_state/wp_interactivity_data_wp_context/wp_interactivity_config helpers, the interactivity-router, or whenever a task involves making server-rendered WordPress markup interactive (toggles, menus, counters, filters, lightboxes, client-side navigation, etc.). Use this even if the user does not mention 'Interactivity API' by name.
WordPress Interactivity API
Declarative data-wp-* directives on HTML paired with a reactive store (state, actions, callbacks). The server pre-renders final HTML from seeded state/context; the client hydrates without re-rendering. Requires WordPress 6.5+.
Hard rules — every one matters
block.json MUST declare "viewScriptModule": "file:./view.js".supports.interactivity: true alone does NOT register the view module. Without viewScriptModule, view.js is never built, nothing hydrates, actions.navigate() falls back to a full reload. Do NOT compensate with wp_register_script_module() in a block's render.php.
The wrapper element MUST carry data-wp-interactive="<namespace>". Use the same namespace string in store(), wp_interactivity_state(), wp_interactivity_config().
render.php MUST start in HTML mode — no leading <?php left open over the markup. Use <?php … ?> only for the PHP bits (a $ctx assignment above the markup, inline for helpers). A bare opener followed by makes PHP parse the HTML as code → fatal error, block never renders.
<?php echo … ?>
<?php
<div …>
WRONG: <?php on line 1, then <div data-wp-interactive="…">…</div> with no ?>.
RIGHT: file begins with <div data-wp-interactive="…" <?php echo get_block_wrapper_attributes(); ?>>…</div> (or a <?php $ctx = […]; ?> block that closes before the markup).
Seed every reactive value on the server, including empty starting values. Anything a directive reads (state.x, context.x, state.derived) must exist before JS runs.
Per-instance values → wp_interactivity_data_wp_context([...]) on the wrapper.
An empty paragraph still needs 'joke' => ''; a placeholder still needs 'title' => '(no post loaded yet)'.
Per-instance UI state lives in local context — that is the default. A counter, a toggle's isOpen, a per-instance fetched value — all wp_interactivity_data_wp_context() + getContext(). Only use wp_interactivity_state() when the value is explicitly shared across instances/blocks (see "Local context vs global state" below).
Derived state is a JS GETTER on state, never a stored field, and never assigned to. The directive ALWAYS binds state.<derived-name> — never context.<derived-name>, even when the underlying source lives in local context. Seed the derived value on the server with wp_interactivity_state() (a closure form when the source is per-instance context) so SDP renders the initial value before hydration. Full pattern in Derived state — per-instance pattern below.
Async actions are generators, not async/await.function* () { yield fetch(...); }. getContext() / getElement() work across yields; async/await breaks scope restoration.
Wrap with withSyncEvent() when the handler synchronously needs event.preventDefault() / stopPropagation() / currentTarget (e.g. router-navigation click handlers). Full list in references/store.md.
Mutate in place.state.list.push(x), context.foo = y. Never state.list = [...state.list, x] — breaks reactivity.
Never hand-duplicate values a directive will populate. WRONG: <span data-wp-text="state.count">0</span>. RIGHT: <span data-wp-text="state.count"></span>. Same rule for bound attributes (omit attribute when data-wp-bind--<attr> is set), toggled classes/styles (don't pre-add them), and data-wp-each (emit only <template> — SDP writes the <li data-wp-each-child> items).
Wire all reactive behavior through directives.data-wp-text (not innerText), data-wp-bind--*, data-wp-class--*, data-wp-on--* / data-wp-on-document--* / data-wp-on-window--*. No addEventListener, classList.*, style.*, or innerHTML from view.js. .focus() for focus management is the one allowed DOM write. Directive values are single references (state.x, !context.isOpen, ns::state.x) — move any arithmetic/comparisons/calls into a derived getter.
Local context is the default. Reach for global state only when the value is explicitly shared across instances/blocks — e.g. "the same counter on every instance on the page", a site-wide filter, a value seeded for data-wp-each, or REST URLs / nonces (which usually go in wp_interactivity_config()). Anything else — a counter, a toggle, a per-instance fetched result — belongs in local context.
// Local context (default) — each instance has its own counter.<?php$ctx = array( 'counter' => 5 ); ?>
<div
data-wp-interactive="my-plugin/counter"<?phpechowp_interactivity_data_wp_context( $ctx ); ?><?phpechoget_block_wrapper_attributes(); ?>
>
<span data-wp-text="context.counter"></span>
<button data-wp-on--click="actions.increment">+</button>
<button data-wp-on--click="actions.decrement">-</button>
</div>
// Global state (only when the prompt asks for a single shared value).<?phpwp_interactivity_state( 'my-plugin/tally', array( 'counter' => 0 ) ); ?>
<div data-wp-interactive="my-plugin/tally"<?phpechoget_block_wrapper_attributes(); ?>>
<span data-wp-text="state.counter"></span>
<button data-wp-on--click="actions.increment">+</button>
</div>
If in doubt, pick local context — dropping two instances of a global-state block onto the same page makes them share a number, which is almost never what the prompt asked for.
Derived state — per-instance pattern
The source of truth (e.g. counter) lives in local context. The derived value (e.g. double) is a JS getter on state that reads from getContext(). The directive binds state.double — never context.double. The server seeds the derived value with a closure inside wp_interactivity_state(), which calls wp_interactivity_get_context() for the current instance.
store( 'my-plugin/joke', {
actions: {
*fetchJoke() {
const context = getContext();
const res = yieldfetch( 'https://example.com/joke' );
const data = yield res.json();
context.joke = data.joke; // mutate AFTER the yield
},
},
} );
For REST + nonce, publish them via wp_interactivity_config(), read with getConfig(), and send 'X-WP-Nonce': nonce in the fetch headers — full example in references/store.md.
Document / window listeners go on the same wrapper that carries data-wp-interactive.getElement().ref is the element carrying the directive, so a data-wp-on-document--keydown on an inner <nav> can't reach a sibling toggle button. Full focus-trap example in references/store.md.
Client-side navigation: the router region wrapper needs BOTH data-wp-interactive AND data-wp-router-region="<id>". The click action is a withSyncEvent generator that calls event.preventDefault(), dynamically imports @wordpress/interactivity-router, and yields actions.navigate(href). Anchors stay real <a href> so things work without JS. Build pagination hrefs with esc_url( add_query_arg( 'pg', $next ) ) — not bare ?pg=<n>, which replaces the whole query string and drops other vars like p=<id> on singular pages. Full pagination example in references/client-navigation.md.