Skip to content
LogoLogo

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 reportingTitleArrow
= .

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:

  • plugins is 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