Dynamic blocks and the Interactivity API
Progressive Enhancement for Blocks
A dynamic block must be useful before hydration: the script module can fail or load late, and a control that appears only after JavaScript is one some never reach.
The block works on your machine. Then a plugin's script throws before the module loader runs, the import map is missing because a build step was skipped, or a corporate proxy drops a module request, and the visitor gets a button that does nothing. The click has no handler, the panel never opens, and the markup that was supposed to apologise for the missing behaviour is not there because there was never any server-rendered version of it.
The Interactivity API is designed around the assumption that this will happen. Every directive is an ordinary HTML attribute, so the document is complete without the API, and PHP resolves what it can before the response leaves the server. Progressive enhancement here is not a technique you add at the end. It is a constraint on what each directive is allowed to carry.
The served HTML is the floor, and it has to stand on its own
Start from the state the page arrives in. A dynamic block with
supports.interactivity has its directives processed on the server, which means
data-wp-text holds the value it read from server state,
data-wp-bind--aria-expanded holds true or false,
data-wp-class--is-open has already added or withheld the class, and
data-wp-each has expanded its template into real list items. A crawler sees that.
A browser with JavaScript disabled sees exactly that. So does anyone whose module request
failed.
<div class="wp-block-m4lab-interactive" data-wp-interactive="m4lab/counter"
data-wp-context='{"step":1}'>
<button type="button" data-wp-on--click="actions.increment">
<span data-wp-text="state.count">3</span> of <span>5</span>
</button>
</div>
That block is legible without hydration: it says three of five. What it is not is operable, and that is the honest limit of a counter. Any block whose whole purpose is a state change has a floor equal to its initial state, and the job is to make that initial state the most useful true thing you can render: the first panel open, the first tab selected, the full list expanded, the form pre-filled with the user's stored values. A collapsed accordion with no JavaScript is a page of headings with hidden content underneath.
A data-wp-* attribute is inert until the module loads
Nothing about data-wp-on--click is a handler. It is an attribute with a name
and a value, and the browser ignores both. Hydration is a single pass over interactive regions
after the @wordpress/interactivity module has run and your store()
call has registered the namespace; only then does the API attach listeners, subscribe
directives to state, and re-render the region.
That pass depends on three things you control, and any one of them missing produces the
same symptom. The module has to be resolvable, which means the import map has to contain
@wordpress/interactivity; a script module whose .asset.php does not
declare that dependency loads, throws
TypeError: Failed to resolve module specifier "@wordpress/interactivity" in the
console, and leaves the served HTML untouched. The block has to declare
"supports": { "interactivity": true } or PHP will not have resolved anything.
And the namespace in store() has to match the string in
data-wp-interactive exactly, or the region hydrates into a store with no
members.
Two details of the loader make that window wider than most people expect. Blocks using the
Interactivity API are treated as not being in the critical rendering path, so their view
script module is registered with fetchpriority of low and printed
in the footer. Between first paint and hydration a visitor can click a button that has no
listener yet. A keyboard user who tabs to it and presses Enter gets a page that does nothing
at all, which is indistinguishable from a broken site.
The markup is a contract, not a fallback
Because the server and the client both resolve the same directives, a bad server state
shows up as a visible flip at hydration. A store that declares
count: 0 in JavaScript while render.php sets it to 3 renders 3 and
then becomes 0 the moment the module loads. Remove the client default and the server value
survives.
Caching a rendered block freezes whatever state it wrote
The output of a dynamic block is a string in a response body, so a full-page cache stores
the resolved directives along with everything else: the data-wp-text values, the
classes, and the JSON block of state that the module reads on arrival. That is fine for state
that is the same for everyone and wrong for state that is not.
A cart count, a wishlist flag, a greeting with a display name, a nonce, or anything read from the current user all end up in the cached copy and become shared. The failure is intermittent and looks like a data bug rather than a caching bug, because the first visitor populates the page and everyone after them sees that visitor's values until the cache expires. The fix has two parts: keep user-specific values out of server state so the served HTML is user-independent, and fetch them after hydration, which is what the API's own configuration and the REST layer are for. A nonce is the sharpest case, because a cached nonce is both wrong and a security problem.
The reverse mistake is also common. State that cannot change between visitors is the best possible thing to render on the server precisely because it is cacheable, and moving it to a client fetch trades a static page for a flash of empty content in exchange for nothing. The question to ask of each key is whether the correct value can differ between two people requesting the same URL at the same moment. If not, it belongs in the HTML.
Testing with JavaScript off is a two-line habit
Fetch the URL with curl and read the markup. That is the disabled-JavaScript
experience for a server-rendered block, because without the module the document is all there
is. Look for controls with no server-rendered content, elements whose text or class depends on
a directive the server could not evaluate, and actions the page offers that cannot be
performed.
The second test is the interesting one, because it finds the difference between what the
server resolved and what the client will decide. Serve the page, capture the values from the
HTML, then load it in a browser and read the same elements after hydration. In WordPress
7.0.7 a block whose render.php sets a count to 3 and whose store declares
count: 0 reads 3 in the served HTML and 0 in the browser, and that single
comparison tells you which keys the client owns. The browser's own JavaScript toggle and a
device emulator are enough for the rest; the discipline is to do it before shipping rather
than after a report.
A control that hydrates into existence is a control some people never get
Accessibility is where progressive enhancement stops being a performance preference. An element that is added to the DOM by JavaScript does not exist for a screen reader that reads the served document, cannot be reached by keyboard while the module is still loading, and disappears entirely when the module fails. If it is the only way to perform an action, then for some visitors the action does not exist.
The failures that matter are specific and easy to spot once you look for them.
data-wp-text on an element that the server never resolved renders as an empty
element, so the document contains a button with no accessible name rather than no button at
all. aria-expanded set only by the client means a screen reader is told nothing
about the panel's state until hydration. A <div> with
data-wp-on--click is not focusable and has no keyboard equivalent unless you add
the role, the tabindex and the key handling yourself. And a panel that is hidden by an inline
style the server did not write is, before hydration, visible content that the design intended
to hide.
The mitigation is to build on elements that already do the job. A disclosure is a
<details> and a <summary>, which opens and closes with
no script at all; use the Interactivity API to keep one open at a time or to animate it, not
to create the behaviour. Tabs are links to anchors, upgraded to a tab widget once the module
has run. An accordion is buttons with aria-expanded rendered on the server, which
is exactly what the core accordion block does: PHP writes the context, evaluates the class
and the expanded state, and the client takes over from a page that already worked.
Take the interactive block from the previous lesson and break it deliberately in three
ways, one at a time. First rename the namespace in the view.js
store() call so it no longer matches data-wp-interactive.
Second, remove the dependencies entry from the view script module's
.asset.php. Third, remove "interactivity": true from
supports.
For each break, capture the served HTML with curl and load the page with
JavaScript enabled, then write down what the visitor sees, what the console says, and
which of your directives stopped being resolved on the server.
Then restore it and add one directive that only the client can evaluate, such as
data-wp-style--display="state.isOpen ? 'block' : 'none'". Check whether the
element it controls is visible in the served HTML, and say whether that is acceptable for
the design.
Finally, make the same block render correctly for a visitor with JavaScript disabled: every control present, named, and stating its own state, and the content it controls reachable without a click.
Show the solution and the reasoning
The three breaks fail differently, and the differences are the diagnosis.
The namespace mismatch is the quiet one. The page renders, the module loads, the store
registers under a name no region uses, and the API finds an interactive element whose
namespace has no store. Nothing resolves and nothing throws; the block is simply static. To
find it, compare the string in data-wp-interactive in the served HTML with the
first argument of store(), character by character. A trailing slash or a
shortened prefix is enough.
The missing dependency is the loud one. Without
'@wordpress/interactivity' in the view script module's dependencies the import
map is empty, so the bare import at the top of view.js fails, and the browser
console reports Failed to resolve module specifier "@wordpress/interactivity".
Everything the server resolved is still there — this is the case where the fallback is
doing real work — and nothing is interactive. It is also the mistake that a hand-written
.asset.php produces, and the one wp-scripts generates for you.
Removing "interactivity": true changes the server's behaviour rather than
the client's. PHP does not process the directives at all, so
data-wp-text has no resolved value, data-wp-each leaves its
template unexpanded, and the bound attributes are absent. The client then hydrates and
renders a correct region, which makes this the most deceptive of the three: the page is
interactive for people who have JavaScript and empty for people who do not.
The expression directive is the case worth thinking about rather than fixing. A conditional style is not evaluatable by PHP, so the element is visible in the served HTML and hidden after hydration. For a decorative element that is a flash. For a panel that should be closed by default it is a disclosure that starts open, which is worse than either state, and the fix is to render the closed state in the markup and let the client upgrade the transition.
A page that satisfies the last instruction usually needs no script at all to be usable:
<details> for disclosures, anchors for tabs, real
<button> elements with server-rendered aria-expanded, and
every list rendered as a list. The module then adds the parts that genuinely require state
shared across a region, and its absence costs behaviour rather than access.
Progress
Mark this lesson done and it stays marked in this browser.