Packaging and Monorepos
Version alignment, building libraries on Expressive, and the one-copy rule
One core per app
@expressive/mvc holds module state: the context registry, prototype brands, and the JSX host registration all live in the loaded module. Two copies of the core in one application are two disconnected worlds - a <Component for> in one and a Model.get() resolving through the other do not see each other. The classic symptom is Could not find X in context. from a component that visibly sits under the right provider.
The packages are shaped to make one copy the default outcome: @expressive/react, @expressive/router, @expressive/dom and @expressive/inspect declare @expressive/mvc as a peer dependency, so the core is installed once, by the app, and every adapter resolves that copy. A React app installs both, and imports Model, Component and the instructions from the core:
npm install @expressive/mvc @expressive/reactWhen the symptom above appears anyway, check for duplicates first:
npm ls @expressive/mvc # or: pnpm why @expressive/mvc / bun pm lsMore than one version listed means something pinned or bounded the core apart from the app's own entry - typically a library that bundled or depended on its own copy. Align the ranges and reinstall.
Building a library on Expressive
A component library or shared package built on Expressive should not bundle it:
- Declare
@expressive/mvcas a peerDependency (with a devDependency copy for local development), the same way you already treatreact. Add@expressive/reactas a peer too only when your package relies on the React host - Model and Component classes themselves need only the core. - Mark
@expressive/*external in your build, so your dist imports it rather than inlining a private copy - an inlined copy is the two-worlds problem above, shipped on purpose. - Keep the peer range wide (
^on the minor you develop against, pre-1.0) and move it deliberately: pre-1.0 minors are the breaking channel.
Consumers of your library install @expressive/mvc and their host adapter themselves - the same contract as react itself. What that costs them: the React adapter has an irreducible floor of about 10.3 kB gzip (measured, budgeted in CI - see Bundle size), paid once per application regardless of how many libraries share it.
Model classes your library exports are ordinary exports. Subclassing, providing, and get()-ing them works across package boundaries because everyone resolves the same core - which is the point of the rules above.
Monorepos
Workspace tooling makes duplicate cores easier to produce: two apps pinning different adapter versions, a hoisting boundary, or a stale lockfile after a version bump. The diagnosis is the same npm ls @expressive/mvc, and the fix is the same alignment - in a workspace, prefer a single version of @expressive/mvc and of each adapter, declared once and shared.
Each adapter's peer range on the core states the compatible window - a caret on the minor it was released against, which pre-1.0 spans that minor only. Upgrade the core and adapters together; a package manager that reports an unmet peer range is pointing at exactly that skew.
Module format
All packages ship ESM only ("type": "module") - no require() entry. Bundlers (vite, esbuild, webpack, rollup) and Metro consume this directly. Jest is the notable exception: its default transformIgnorePatterns skips node_modules, so add an allowance for @expressive if your tests import it (jest-expo included). Vitest needs nothing.
Beyond its main entry, @expressive/mvc publishes a few subpaths: @expressive/mvc/observable (the low-level observable helpers - see API: Utilities), @expressive/mvc/hot (the class hot-reload registry that build integrations such as @expressive/react/vite call into), and @expressive/mvc/jsx-runtime (the host registry an adapter registers into). Apps rarely import the last two directly.
Next
- Bundle size - measured cost per import shape, and what tree-shakes.
- Getting started - installation and first
Model.