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.
Getting started
Install
npm install @evanion/react-widgetyarn add @evanion/react-widgetpnpm add @evanion/react-widgetReact 18 or 19 is the peer dependency. Node 20 or newer. The package is ESM only.
Three pieces
A widget set is a component map, a list of items, and the Widgets component
that renders one against the other. Baize’s shelf is two widget types — a
listing card and the table-booking strip — and the items the shop’s data gives
it.
import { } from '@evanion/react-widget';
const = ({ , }: { : string; : number }) => (
< ="listing">
<>{}</>
<>{} kr</>
</>
);
const = ({
,
,
}: {
: number;
: boolean;
}) => (
< ="booking">
<>{} tables</>
<>{ ? 'tonight' : 'this week'}</>
</>
);
// Call once, at module scope.
const { , } = ({
: { : , : },
: {
: ({ }) => < ="shelf">{}</>,
},
});
export function () {
return (
<
={[
{ : 'b1', : 'booking', : { : 4, : true } },
{
: 'g1',
: 'listing',
: { : 'Brass: Birmingham', : 649 },
},
]}
/>
);
}That page can be a Server Component. Nothing in the package forces the widgets
into the client bundle; a widget that needs client state carries its own
'use client' and nothing else changes.
Why once at module scope
createWidgets is a factory, not a hook and not a provider. Calling it inside a
component builds a new Widgets identity on every render, which remounts the
whole region. There is no provider to call it from instead: React’s
react-server export condition has no createContext.
The item shape
{
id: string; // stable identity, used as the React key
type: keyof typeof components; // which component to render
props: ComponentProps<That>; // minus `children` and `ctx`
meta?: M; // for chrome.item only
children?: Item[]; // only if that component accepts children
}props excludes children and ctx because the renderer supplies both. A
widget may declare ctx required without every item having to repeat a value
<Widgets> is going to pass anyway, and an item cannot override it.
A widget whose component declares no props at all gets
props: Record<string, never> rather than {}. TypeScript assigns any object
to {} without an excess-property check, so props: { totally: 'bogus' } on a
zero-prop widget would otherwise compile clean — a hole in the guarantee,
opening exactly where the component is simplest.
Typing items held in a variable
Written inline in JSX, an item array is already contextually typed. Assigned to
a variable it is not: const items = [{ type: 'listing', … }] infers
type: string, which cannot narrow to the map’s keys, and the check is silently
lost.
defineItems is the identity function that supplies the type:
const { Widgets, defineItems } = createWidgets({
components: { listing: ListingCard, booking: TableBooking },
});
const items = defineItems([
{ id: '1', type: 'listing', props: { title: 'Wingspan', price: 549 } },
{ id: '2', type: 'nope', props: {} }, // ✗ not a key of the map
{ id: '3', type: 'booking', props: { tables: 'warm' } }, // ✗ tables is a number
]);satisfies WidgetItem<typeof components>[] works too, if you would rather not
destructure a second name off the factory.
Nesting
Nested items render as the parent component’s children:
const Card = ({ title, children }: PropsWithChildren<{ title: string }>) => (
<section>
<h3>{title}</h3>
{children}
</section>
);
const { Widgets } = createWidgets({ components: { card: Card, text: Text } });
<Widgets
items={[
{
id: 'c1',
type: 'card',
props: { title: 'My card' },
children: [{ id: 't1', type: 'text', props: { content: 'Nested' } }],
},
]}
/>;children is type-gated: permitted only when the mapped component actually
accepts children, and typed never otherwise. Nesting under a widget that
would drop the child items is a compile error rather than content that silently
disappears.
There is no Output component and no outlet prop. A widget renders its nested
items by rendering children, like any other React component.
What happens to bad data
The type checker covers items you wrote. For a CMS payload it does not, and the
renderer stays defensive: a malformed item or an unknown type is skipped, and
a non-array children is dropped and the widget rendered without it, each with
a development-only console.warn rather than taking the page down.
Each message is logged once per process, keyed on its own text — which carries
the offending item’s id and type, so a second bad item is still reported
separately. Nothing is logged when NODE_ENV is production.
Run the loud check yourself at ingestion or in CI:
import { validateItems } from '@evanion/react-widget';
const problems = validateItems(payload, ['listing', 'booking', 'card']);
if (problems.length) {
console.error(problems);
process.exit(1);
}Widgets never calls it. What it reports is the message
catalogue: every line validateItems can return and the payload that produces
it.
Next
- Chrome,
metaand Suspense — the composition seams - API reference — every export
- Examples