Skip to Content
React WidgetAdvanced Patterns

Ahead of the release

npm install @evanion/react-widget gives you 0.3.0. These pages document main, which has changes that release does not.

Chrome, meta and Suspense

Chrome

A widget set has two wrappers. chrome.wrapper goes around the whole region; chrome.item goes around each widget.

const { Widgets } = createWidgets({ components: { listing: ListingCard, booking: TableBooking }, chrome: { wrapper: ({ children }) => ( <aside className="shelf"> <header>Latest updates</header> <div className="widget-container">{children}</div> </aside> ), item: ({ children, ...rest }) => ( <div className="widget-item" {...rest}> {children} </div> ), }, });

The wrapper receives children and an optional items, the region’s items in the order they render. The item chrome receives children, data-widget-id, data-widget-type and meta.

Spread the data-widget-* attributes onto the element unless you have a reason not to: CMS click-to-edit overlays, analytics and E2E selectors key off them, and a custom chrome that drops them silently breaks all three.

The defaults are DefaultWrapper (a <section>) and DefaultItem (a <div> with those attributes). Both are exported, so a custom chrome can wrap one rather than reimplementing it.

Per-instance overrides

<Widgets> takes components and chrome of its own, merged over the factory’s:

function StorefrontPage() { return ( <Widgets items={items} components={{ listing: FeaturedListing }} chrome={{ wrapper: HeroRegion }} /> ); }

components is a shallow merge of the two maps; chrome is resolved field by field, so overriding wrapper keeps the factory’s item, suspense and suspenseFallback.

meta: placing a widget without telling it where it is

A dashboard grid, a masonry board or a CMS page with per-block spans needs placement data. That data belongs to the wrapper: a widget renders the same at column 1 and at column 7.

meta is handed to chrome.item and is never spread into the widget’s props.

import type { WidgetItemComponent } from '@evanion/react-widget'; type GridMeta = { column: number; columnSpan?: number }; const GridItem: WidgetItemComponent<GridMeta> = ({ children, meta, ...rest }) => ( <div {...rest} style={{ gridColumn: `${meta?.column ?? 'auto'} / span ${meta?.columnSpan ?? 1}`, }} > {children} </div> ); const { Widgets } = createWidgets({ components: { chart: Chart }, chrome: { item: GridItem }, }); <Widgets items={[ { id: 'today', type: 'chart', props: { metric: 'revenue' }, meta: { column: 1, columnSpan: 4 }, // meta: { colunm: 1 } ← compile error: not a key GridItem reads }, ]} />;

Annotating chrome.item with the vocabulary it reads is what types every item’s meta, at the top level and inside children. Without that, a misspelled key compiles and the item is placed by whatever fallback the chrome applies — a layout that looks deliberate.

There is no type argument to pass. createWidgets infers the vocabulary from the chrome.item it is given: an annotated component, a plain function with an annotated parameter object, or a memo()-wrapped one all work.

Typing meta removes the typo, not the narrowing. meta is optional on every item, so a chrome that needs a key still writes the fallback for the item that omits it — the ?? 'auto' above.

DefaultItem drops meta rather than forwarding it. React warns about an unknown attribute for every key of an arbitrary object that reaches a DOM element.

ctx: page-level data for every widget

<Widgets ctx={{ locale, currency }} items={items} />

Every widget receives ctx as a prop. This exists instead of a context provider: React’s react-server condition has no createContext, and the package has to be importable from a Server Component.

ctx is the renderer’s to supply, so it is omitted from an item’s props alongside children. A widget may declare it required without every item repeating a value <Widgets> is going to pass anyway, and an item cannot override it — ctx follows the props spread, and items are untrusted input.

The counterpart in @evanion/astro-widget is the same prop by the same name.

Suspense

By default every widget gets its own <Suspense> boundary, so one suspending widget does not block its siblings. The fallback comes from chrome.suspenseFallback and defaults to nothing.

const { Widgets } = createWidgets({ components: { report: AsyncReport }, chrome: { suspenseFallback: <Skeleton /> }, });

The boundary lives in the renderer rather than in the item chrome, so replacing chrome.item cannot silently remove it.

There is no default skeleton. A region is a dashboard grid for one consumer and a table of rows for the next, and one generic placeholder would be wrong in both.

Synchronous regions: chrome.suspense

A boundary costs more than its markers when the region is large. React’s streaming SSR outlines any boundary it has not finished by the time the shell passes progressiveChunkSize — 12,800 bytes by default — whether or not anything in it suspended. The content is written to a trailing <div hidden> and an inline <script>$RC(…)</script> moves it into place.

Measured over 150 synchronous items of about 1,000 bytes each, streamed with the default chunk size:

chrome.suspensebytesdeferred boundariesrows in the shell
per-item139,2331455 of 150
none122,0690150 of 150

So for a region whose widgets are all synchronous, say so:

const { Widgets } = createWidgets({ components: { row: LedgerRow }, chrome: { suspense: 'none' }, });

A client that does not run the inline scripts never sees outlined content. It is in the HTML, inside <div hidden>, and $RC is what moves it — which covers scripts disabled and a Content-Security-Policy rejecting inline script without a nonce. Anything reading the HTML in document order sees placeholders where the content should be, and the content at the bottom in completion order: a text extraction, a reader-mode pass, a diffing snapshot test, curl | sed.

There is no detection and no heuristic. An async function component and React.lazy are recognisable at runtime; a component calling use(promise) is not, memo() hides both, and an async function downlevelled below ES2017 becomes a plain function. Guessing wrong drops the boundary from a widget that does suspend, which is worse than paying for one that does not.

Under none a widget that suspends anyway suspends whatever boundary is above the region, up to the page. Put your own <Suspense> around <Widgets> if that should be the region rather than the page — which is also how you get one boundary for the whole set, since there is no region-wide setting. One boundary around the whole set outlines the whole set: over those same 150 items, per-item keeps 5 rows in the shell and a single region boundary keeps none.

The host has a knob too: progressiveChunkSize on renderToPipeableStream takes the deferral to zero, and renderToString never defers. That is the framework’s entry.server to set, not the library’s.

Error boundaries

The package ships none. A React error boundary requires a class component, which React does not expose under the react-server condition, so a default boundary would put a 'use client' directive on the whole package and drag every widget into the client bundle with it.

Add your own in a custom chrome.item, in your own 'use client' file:

'use client'; import { ErrorBoundary } from 'react-error-boundary'; export function SafeItem({ children, ...rest }) { return ( <div {...rest}> <ErrorBoundary fallback={<p>This widget failed.</p>}> {children} </ErrorBoundary> </div> ); }
const { Widgets } = createWidgets({ components: { listing: ListingCard }, chrome: { item: SafeItem }, });

The boundary is then client code and the widgets inside it are not, which is the split the package is arranged to allow.

Large regions

The renderer is eager: every item in the list is rendered, and there is no windowing option. Two recipes cover the cases that made people ask for one.

For server-rendered, SEO-relevant content, do not virtualize — put content-visibility: auto on the item chrome. It removes the layout and paint cost of off-screen blocks while leaving every one of them in the HTML:

.widget-item { content-visibility: auto; contain-intrinsic-size: auto 240px; }

For a client-side dashboard where the list is long enough to matter, virtualize in your own 'use client' chrome.wrapper with @tanstack/react-virtual. The wrapper receives items beside children, positionally aligned, so it measures and windows by item and indexes into React.Children.toArray(children) — one entry per top-level item, already rendered as elements. That keeps the virtualizer a dependency of the application rather than of this package. Only the top-level list of a region is ever a candidate; nested items are not.

Last updated on