Plugin state
The library reads two fields — id and contributes — and nothing else. It has
no state, no lifecycle and no command registry, on purpose: it knows who
contributes what, in what order, and what happens when a contribution breaks.
Everything else is yours.
Why a store at all
On the declarative channel a plugin's contributions do not share
a React subtree, so they do not share ordinary useState. Two contributions
of one plugin are two separate places in the tree that happen to carry the same
pluginId. A store takes the place of the shared subtree.
On the runtime channel the question does not
arise: a feature that fills through the createSlot() façade is one subtree,
so its parts share ordinary state and you need none of this page.
Extend the manifest
definePlugin is generic over its argument, so your own fields keep their
types. Declare your plugin shape once and let the library ignore the parts that
are not its business.
// The manifest is open data. `definePlugin` is generic over its argument, so
// your own fields keep their types — the library reads `id` and `contributes`
// and nothing else.
export type = & {
: string
?:
/** Server-side initial state for this plugin's slice. */
?: () => <unknown> | unknown
?: () => object
?: (: ) => (() => void) | void
}Use satisfies CrmPlugin at each declaration rather than annotating the
constant. The check still runs, and the literal keeps its exact type — which is
what lets the application read a field back without a cast:
// `satisfies` checks the shape without widening it, so the literal keeps its
// exact type and the application can read a field back without a cast.
export const = ({
: "reporting",
: "Reporting",
}) satisfies
export const reportingTitle = .
One rule
Assemble from the catalog before render. Never inject in an effect.
An effect does not run on the server, so state injected from one cannot be preloaded — and a plugin whose slice appears a commit after hydration is a layout shift at best and a mismatch at worst.
redux
A plugin declares its reducer, and a preload for that slice's server-side
initial state. The application combines slices from the catalog, not from
the enabled list: a store whose shape depends on a toggle cannot be preloaded.
// Combine slices from the *catalog*, not from the enabled list: a store whose
// shape depends on a toggle cannot be preloaded.
export const = ({
: "pipeline",
: "Pipeline",
: ,
: () => (),
}) satisfies
export function (: readonly [], : object) {
const = .(
.(() =>
. ? [[., .]] : [],
),
)
return (, )
}The server sends the loaded state with the HTML and the client starts from it.
mobx
A plugin declares createStore, and the application creates one instance per
application instance — which on the server means per request. A contribution
finds its own store through useContribution().pluginId, the identity only the
library knows while it renders.
// One store instance per application instance — on the server, per request.
// A contribution finds its own through `useContribution().pluginId`, the
// identity only the library knows while it renders.
export const = ({
: "telephony",
: "Telephony",
: () => ({ : null as string | null }),
}) satisfies
export function () {
const = <{ : string | null }>(
().,
)
return <>{. ?? "Idle"}</>
}This suits ephemeral, client-only state — a live call, an open dialog — which has nothing to serialise in the first place.
setup runs in an effect
The library has no lifecycle, so a plugin that needs one declares it as a field and the application runs it. An effect is the right place: the plugin list is already known, and teardown is the returned function.
Anything setup registers is absent from server markup by construction, and
appears a moment after hydration. That is fine for commands, shortcuts and
listeners. It has one trap:
// `setup` runs in an effect, so whatever it registers is absent from server
// markup by construction. Wrap the registration in `startTransition`: an urgent
// update reaching a boundary that has not hydrated yet makes React throw away
// the streamed HTML.
export function (: readonly [], : ) {
(() => {
let : (() => void)[] = []
(() => {
= .(() => .?.() ?? [])
})
return () => {
for (const of ) {
()
}
}
}, [, ])
}An urgent update reaching a Suspense boundary that has not hydrated yet makes
React throw away the streamed HTML and re-render it on the client — "This
Suspense boundary received an update before it finished hydrating." Wrapping
the registration in startTransition is what keeps streamed markup intact. See
Streaming and Errors.
Two details in that hook are load-bearing:
pluginsis a dependency, so the effect re-runs when the list changes and the previous teardown runs first. Keep that array stable — the effect, unlike a host, has no content comparison to fall back on.- Teardown is collected outside the transition, because the cleanup must run synchronously when the component unmounts.
Reading state from a contribution
A declared contribution gets the host's props, and nothing else. Everything
per-plugin it needs — its slice, its logger, its settings namespace — it looks up
by useContribution().pluginId, as CallIndicator does above.
useContribution throws outside a contribution, so a component that reads it
cannot be mounted in the wrong place and quietly get somebody else's state. See
Errors.
Next
- Recipes — resolving exclusive claims, building an inventory
- Server rendering — preloading state with the HTML
- Performance — what a re-render costs, and what stays free