RRelayer
Home/Features

Defer Components#

For parts that "differ per user but should otherwise stay cached" — such as the login name, the cart count, or an A/B bucket — split the component into two.

  1. Base component — does the actual rendering. Reusable inline too.
  2. Defer wrapper — a wrapper that only carries the Defer config.

The page references the wrapper, and at SSR only the fallback is embedded. The body is fetched after page load via the dedicated endpoint GET /_defer/{name}, and the placeholder is replaced in place. Since the main HTML does not depend on user state, it can be cached at the CDN edge.

<?php
// src/Components/UserHeader.psx — the body (reusable inline too)
return fn (array $props) => (
    <header>Hello {$_SESSION['user']['name'] ?? 'Guest'}</header>
);
<?php
// src/Components/UserHeaderDeferred.psx — the defer wrapper
use Polidog\UsePhp\Component\Defer;
use function Polidog\UsePhp\Runtime\fc;

return fc(
    fn (array $props) => <UserHeader />,
    defer: new Defer(name: 'user-header', cacheControl: 'private, no-store'),
);
{/* Page side — fallback is passed as an ordinary prop */}
<UserHeaderDeferred fallback={<HeaderSkeleton />} />

With a class component you can do the same thing with attributes.

#[Component(name: 'UserHeaderDeferred')]
#[Defer(name: 'user-header', cacheControl: 'private, no-store')]
final class UserHeaderDeferred extends BaseComponent
{
    public function render(): Element { /* the actual content */ }
}

No manual registration or route setup needed#

To use the defer endpoint, there is no need to define a route, register anything in the DI container, or hand-edit a manifest. usephp compile discovers the Defer config automatically and writes out deferred-manifest.php. The router dispatches GET /_defer/{name} to UsePHP::handleDeferred() before page/layout processing, so the endpoint comes up just by writing the wrapper. The endpoint returns the Cache-Control registered with it, so you can decide the caching strategy per component: a shared announcement bar with public, s-maxage=60, and a session-dependent UserHeader with private, no-store.

Passing values from parent to child#

Props other than fallback automatically become a query string.

<PostCommentsDeferred fallback={<Skeleton />} post_id={$postId} sort="new" />

This becomes GET /_defer/post-comments?post_id=123&sort=new, and the closure inside the wrapper receives it as $props['post_id']. Scalars only (int / string / float / bool), and since they go over the URL the values are stringified. Arrays, Elements, and Closures cannot be passed.

Client cache#

usephp.js places two cache layers in front of the defer fetch.

  • L1 — in-memory. For the page's lifetime only. Always on, and cleared

by a full reload (the traditional behavior).

  • L2 — localStorage. Spans reloads/tabs. Fully opt-in — only

components with Defer::$localCache = true are stored (usephp.js does not look at the HTTP Cache-Control). This design prevents the previous user's defer content from leaking on a shared device.

#[Defer(name: 'announcement-bar', localCache: true)]
// or in .psx
fc($render, defer: new Defer(name: 'announcement-bar', localCache: true));

By default it does not expire over time. Invalidate it by bumping DEFER_CACHE_VERSION (at deploy time) or with clearDeferCache() (at runtime). To expire by elapsed time, add localCacheTtl (seconds) (localCache: true is required; specifying a positive TTL alone throws an exception).

#[Defer(name: 'feed', localCache: true, localCacheTtl: 60)]
window.usePHP.clearDeferCache();                // clear both layers entirely
window.usePHP.clearDeferCache('post-comments'); // a specific defer name

Explicit reload#

By default a defer fragment is fetched once and the whole wrapper goes away. Opting into Defer::$reloadable keeps a re-fetchable wrapper in the DOM (for re-fetching a list after a form update, etc.).

#[Defer(name: 'todo-list', reloadable: true)]
window.usePHP.reloadDefer('todo-list'); // imperatively re-fetch
{/* auto re-fetch after form submit / re-fetch on click of any element */}
<form data-usephp-form data-usephp-reload-defer="todo-list"> … </form>
<button data-usephp-reload-defer="todo-list">Refresh</button>

A reload always discards both cache layers for that URL first, so it always reflects the latest server state.

Constraints#

  • A separate component per mode. Inline = base, defer = the wrapper

with Defer. Switching call sites on the same component is not supported.

  • **The defer name is URL-safe ([A-Za-z0-9_-]+) and unique within the

app.** A duplicate is a compile-time error.

  • params are scalars only. Because they go via the query string.
  • Authorization is the component's responsibility. The name and params

are exposed in the URL, so if you return sensitive data, check the session/permissions yourself.

  • Nested defers also work. If the resolved output contains further

defers, they are hydrated recursively.

  • Users without JS see only the fallback. A deliberate trade-off.

This documentation site itself does not use Defer Components (server-rendered only). Note that $ctx->js($src, defer: true) refers to the <script defer> attribute and is a different thing from the Defer Components discussed here (→ "Per-Page Scripts").

Last updated: 2026-05-19
Change history (1)
  • Created