expressive-mvc
Class-based reactive state management for React (Expressive MVC). Use when writing or refactoring React state - converting useState/useEffect/useMemo hooks, fixing prop drilling, choosing state ownership (Model vs Component, has-pool domain rows, region controls), dependency snapshots, presence boundaries with get(true), providing context, async suspense, router - and when auditing a codebase for fit.
中文简介正在准备中。当前显示原文说明。
实测评分 未实测
查看安装方法包含的文件(26)
- SKILL.md23.3 KB
- design.md10.5 KB
- dom/dom.md17.5 KB
- examples/audit.md5.0 KB
- examples/basic.md5.7 KB
- field/def.md2.4 KB
- field/get.md3.7 KB
- field/has.md12.3 KB
- field/map.md8.1 KB
- field/ref.md3.3 KB
- field/set.md5.3 KB
- inspect.md20.9 KB
- react/component.md17.2 KB
- react/patterns.md17.8 KB
- react/react.md15.1 KB
- react/refactor.md29.5 KB
- react/style.md3.0 KB
- router/production.md7.0 KB
- router/router.md21.5 KB
- state/computed.md6.0 KB
- state/context.md11.4 KB
- state/get.md7.1 KB
- state/lifecycle.md12.0 KB
- state/set.md6.7 KB
- state/state.md12.0 KB
- state/types.md2.2 KB
SKILL.md(原文)
安装前可以查看交给 AI 代理的指令内容。
Expressive MVC
Class-based reactive state for React. Model classes define reactive properties, computed values, async data, and context - all as plain class fields using instruction helpers.
Packages
| Package | Status | Description |
|---|---|---|
@expressive/mvc | Published | Framework-agnostic core. Primary import for Model, Component, instructions. |
@expressive/react | Published | React adapter. Supplies the host. |
@expressive/dom | Preview | MVC-native client DOM renderer without a framework dependency. |
@expressive/preact | Private | Thin wrapper over React adapter via preact/hooks. Prerelease. |
@expressive/router | Published | Host-agnostic, class-based router built on MVC. |
@expressive/inspect | Published | In-process inspector: registry, ownership tree, path queries, journal. |
Installing
npm install @expressive/mvc @expressive/react # + @expressive/router for routing
@expressive/mvc is a peer dependency - add it to package.json and import Model, Component and every instruction from it, whatever the host. @expressive/react supplies the host: import it once from your entry module so it registers. Its re-exports of the core are deprecated and will be removed, as is the default export of Model - adapter-augmented Model.* types are invisible through the default alias, so import { Model } by name.
React Native / Expo need no setup beyond three boundaries (react/react.md): add @expressive to jest-expo's transformIgnorePatterns; use Router, not BrowserRouter; Link/NavLinks render DOM, so navigate through Router.
For a client-only DOM app without React, install @expressive/mvc and @expressive/dom. @expressive/dom is a sidecar and re-exports nothing - Model, Component and instructions come from @expressive/mvc, while render, Portal, style and macro come from @expressive/dom. Its FC semantics, renderer API, portals, code splitting, and transition constraints are in dom/dom.md.
Start With Ownership, Not APIs
The most common failure when adopting this library is translating hooks one-for-one before deciding who owns each behavior. Resolve ownership first; APIs come second.
For every stateful concern, pick exactly one owner:
Component- state intrinsic to one display subtree: inputs, panels, editors, review/confirm surfaces. Usually definesrender(). Fields, handlers, and rendering live on one class. The app/route entrypoint is one even whenrender()only composes: it owns construction (replica + region control fields), provides implicitly, and ships last-resortcatch/fallback-mainjust mounts it.Model- headless domain model or workflow: network operations, domain rules, cross-view coordination. Views subscribe viaModel.get()(orModel.use()on React). Two roles: a domain model (Cart,Message, a pool row) outlives any one placement; a control (Control,<Feature>Controlin apps) is constructed where a page, region or route places it, holds domain models as fields, and translates gestures into operations on them. Name a class for what it holds -<Thing>Stateand<Feature>Controlare both fine, case by case.- Plain function component - simple presentation, or trivial local UI state. Not everything needs a class.
Concerns include the rows: UI state describing an entry in a collection - selection, status, progress - is its own Model or Component spawned in a has pool; actions about item belong on item. Id-keyed fields (Record<Id, T>, parallel Maps) and (id, value) methods are a missing class; subsets are a second pool admitting the same members.
Counter-rules:
- Do not create
FooStateplusFooViewjust because hooks were present. If the behavior and rendering are one unit,class Foo extends Componentis the refactor. - Avoid
Componentwhere a providedModelsuffices - Components carry React instance surface (props,state,setState,forceUpdate) that makes.get()IntelliSense noisier. - Prefer an FC over
Componentwhen state is zero or reducible and no boundary is wanted - a class holding onlyget(...)fields plusrender()is an FC snapshotting.get(). - A render-less
Component(children pass through while providing context and boundary placement) is only for cases where React tree placement is the feature: route controls, progressive boundaries. - A provided instance implicitly provides its child instances - prefer
theme = new Theme()on an existing owner over stacking<Component for>elements for every small control.
Golden-Path Refactor Algorithm
Before refactoring existing React code, read react/refactor.md - it expands every step with before/after examples and ends with the review checklist. The short form:
- Identify lifecycle and ownership boundaries before translating any hooks.
- Separate headless workflow state from display-intrinsic state.
- Choose
Model,Component, or a plain function component for each owner. - Give every repeated UI entry own class in a
haspool; actions about item belong on item. - Split unrelated clusters remaining on page control to owned region controls
composer = new Composer(). Bias one concern per class - barrels are deliberate (page orchestrator, pool owner, mounting shell); shed a second concern the moment it appears, growing a feature or refactoring one alike. - Provide classes directly
<Component for={AppState}>never an instance if only to provide it; the entrypoint Component's own fields provide implicitly -mainonly mounts<Inbox />. - Move source fields and behavioral methods first; do not mechanically translate setters.
- Keep shared, semantic derivations as getters; leave single-consumer display derivations in their consuming component.
- Let contextual children call
.get()instead of receiving drilled props. - At every
.get()/.use(), destructure for exact nested dependency snapshot. - May assign thru subscribed proxies;
isonly to retain the root object alongside sibling destructuring. - A gated widget mounts unconditionally and gates itself, falling thru when unset; parent gate +
.get(true)where the parent reads the field for its own content. - Split
render()and long JSX at conditionals and non-interacting siblings into honestly-named local FCs - units you'd delete or move whole; one-op conditionals and formatted scalars stay inline. Consolidate scopes that share dependencies and hold no nested logic. - Audit the result against the checklist in react/refactor.md.
For large apps, scope a one-shot conversion to route/page controls and their domain pools; leave mature leaf widgets on hooks until parent domains stabilize.
Write output in the conventions of react/style.md. They are opinion, not semantics - but they exist to keep reactive dependencies auditable, and the golden path applies them by default.
Core API
import { Model, Component, ref, def, get, has, map, pending, set } from '@expressive/mvc';
import '@expressive/react'; // once, in the entry module
Model Class
Extend Model to define reactive classes. Use Model.new() to construct a root instance - it constructs and activates, which plain new does not. Bare new is correct in one place: a class field on another Model (theme = new Theme()), where the owner adopts and activates the child, and destroys it in turn.
class Counter extends Model {
count = 0;
increment() {
this.count++;
}
}
const counter = Counter.new();
counter.count; // 0
counter.increment();
counter.count; // 1
Properties assigned in the class body are reactive - updates notify subscribers. Methods are auto-bound.
Instructions & Reactive Helpers
Field initializers that configure reactive behavior. Each has multiple overloads - fetch the reference when a task needs them.
| Helper | Use for | Reference |
|---|---|---|
set() | Defaults, placeholders (suspend until assigned), lazy/async factories (suspense), setter callbacks and validation | field/set.md |
get() | Context lookup between instances - required or optional upstream, downstream collection | field/get.md |
ref() | Mutable refs (.current), ref callbacks with cleanup, ref proxies | field/ref.md |
map() | Reactive Map field - keyed entries or a keyed spawner, with owned Model members and direct render | field/map.md |
has() | Owned collections - an ordered list of values, or a pool of spawned members. Pools are for per-item UI state (selection, progress, row actions). Class first (has(Row)); factory when the seed isn't the init | field/has.md |
def() | Low-level custom property behavior | field/def.md |
For computed values, declare a normal class getter - getters on a Model subclass are auto-promoted to memoized, dependency-tracked properties. See state/computed.md for tracking rules and when a derivation should not be a getter.
pending(work) is not a field helper but imports alongside them - it marks the updates work queues non-urgent and resolves once every reader has taken them. See Transitions.
Do not pass a bare promise to set(). Use set(() => promise) or set(async () => value) so work starts during activation/access instead of construction.
class UserProfile extends Model {
userId = set<string>();
user = set(async () => {
const res = await fetch(`/api/users/${this.userId}`);
return res.json();
});
email = set('', (value) => {
if (!value.includes('@')) throw false;
});
get displayName() {
return `${this.user.firstName} ${this.user.lastName}`;
}
}
React Hooks
// Local state - creates instance, owns lifecycle, subscribes to accessed fields
function MyComponent() {
const { count, increment } = Counter.use();
return <button onClick={increment}>{count}</button>;
}
// Context state - reads nearest provided instance, subscribes reactively
function Child() {
const { count } = Counter.get();
return <span>{count}</span>;
}
// An instance you already hold - place it; it renders and subscribes itself
function Parent({ counter }: { counter: Counter }) {
return <section>{counter}</section>;
}
Static .use() and .get() are React hooks - call unconditionally at the top of a component or render(), never inside branch, event handlers, or loops. They build green when misused and crash at runtime. (Instance method get is not a hook.)
Use Model.use() when the component should create and own the instance. Use Model.get() when the instance comes from context. To render an instance you already hold, make it a Component and place as {instance} - subscription belongs to the placed instance, not the surrounding function. See react/react.md for overloads (optional lookup, required values, computed selector).
The Dependency Snapshot
Open every subscribing component by destructuring the exact reactive values it renders - nested ones included. Nested observable reads are proxied and tracked automatically, so nested destructuring subscribes to child fields; a child reached through a parent proxy needs no separate subscription.
function OrderSummary() {
const {
status,
customer: {
name,
address: {
city,
} = {},
},
} = Order.get();
return <p>{name} ({city ?? 'no address'}) - {status}</p>;
}
This is the norm, not a preference:
- The component's complete dependency surface is visible at the top - reviewable at a glance.
- Each trapped getter is traversed once, instead of re-walking
order.customer.address.cityin every expression. - Reads create subscriptions. A deep read buried in a conditional branch subscribes only on renders where that branch runs - a conditional subscription. Hoisting reads into the snapshot makes the dependency surface deterministic.
Optional nested objects take in-place defaults (= {}) rather than a separate unwrap step. The same rule applies to this inside Component.render() and subcomponents - injected parents (inbox = get(Inbox)) are read thru that snapshot, never a second Inbox.get().
Transparent Writes
Proxies pass assignments through to the instance, nested objects included - no unwrapping:
const form = LoginForm.get(); // whole object needed - take it directly
<input value={form.username} onChange={(e) => (form.username = e.target.value)} />
const { transfer, confirmed } = ReviewStep.get();
<button onClick={() => (transfer.step = 'generate')} disabled={!confirmed} />
const { is: review, confirmed, hasBlocking } = ReviewStep.get(); // `is`: root beside siblings
is is only for keeping the root alongside sibling destructuring. Unwrapping every writable object through it is the most common misuse.
Presence Boundaries & get(true)
Default: a self-contained widget mounts unconditionally, reads its own context, and falls thru when its reason to render is unset - a parent read existing only to gate belongs in the widget. The secondary shape, when the parent reads the field for its own content: parent gates, child asserts with get(true):
function SettingsContent() {
const { draft } = SettingsState.get();
return (
<div className="settings-layout">
<LocationList />
{draft && <SettingsEditor />}
</div>
);
}
function SettingsEditor() {
const {
saveSettings,
saving,
draft: {
bankAccount,
categoryAccounts,
},
} = SettingsState.get(true); // Required<T> - throws if an accessed value is undefined
return <section className="settings-editor">...</section>;
}
This gives the child a strong contract - no fallback values threaded through its body. Declare gateable fields optional (draft?: SettingsLocation), not | null: the runtime check rejects only undefined, and Required<T> does not strip null from a union (see react/react.md). Both shapes in full: react/refactor.md step 12.
Providing Context
Pass the class: <Component for={TransferState}>. Pass an instance only when something else owns it (<Component for={counter}> after Counter.use()). One item per element - several belong to a parent instance owning them as fields, which makes them siblings; nesting <Component for> is a smell (the outer cannot get the inner). is, attributes, boundaries: state/context.md.
Component Class
A Component is a Model usable in JSX. It provides context automatically and supports suspense/error boundaries.
class CounterView extends Component {
count = 0;
increment() {
this.count++;
}
render() {
const { count, increment } = this;
return <button onClick={increment}>{count}</button>;
}
}
<CounterView />;
const counter = CounterView.new();
<>{counter}</>;
An activated instance renders directly, arrays included; its owner keeps lifecycle, so unmount detaches without destroying.
PascalCase methods become reactive subcomponents - extension points a subclass would replace or wrap, not general decomposition. Otherwise write a freestanding FC calling MyComponent.get(). Details: react/component.md.
Rules & Counter-Rules
Every broad rule here has a locality constraint. Apply both halves. When auditing a result, weigh findings by the severity labels defined in react/refactor.md - invariant, default, heuristic, style - and never fail a heuristic on its numerical signal alone.
| Rule | Counter-rule |
|---|---|
| Reactive fields are assigned directly | Keep a method when the write validates, normalizes, coordinates fields, or triggers behavior. Delete methods whose body is only this.x = value. |
| Derived values become getters | Only when shared by multiple consumers, semantic to the domain, expensive, or a deliberate part of the instance's API/introspection surface. Single-consumer display derivations live in the consuming component - but judge meaning, not reference counts. |
Contextual components read via .get() | Pure presentation components may still take plain props. Context replaces drilled state, not every value. |
| PascalCase subcomponents compose renders | Only for genuine extension points a subclass would replace or wrap. Implementation scopes are freestanding FCs using .get(). |
| Extract long conditional JSX (~10+ lines or ~5+ levels) | Keep branches together when they share dependencies, read locally, and contain no nested logic. |
is retains the raw instance | Only alongside sibling destructuring from the same snapshot. Writes through proxies are transparent; nested objects need no unwrapping. |
| State about a collection entry lives on the entry's class | Payload keys that never drive UI stay as one info subobject on the member - a reactive field without a reader is pure cost. |
File Reference
Fetch these for detailed documentation when the task requires deeper knowledge. Read react/refactor.md in full before any hook-migration or refactor task.
Design intent
- design.md - why the library is shaped this way: classes hold the model layer not views, the "MVC" mapping, the two-verb
get/setsurface, render as an ordinary override, typed lifecycle hooks, transparent writes. Read before characterizing a design choice as accidental. For positioning against alternatives (Zustand, Jotai, MobX, Redux Toolkit, plain Context), see expressive.dev/llm/comparisons.md.
Golden path
- react/refactor.md - the refactor algorithm expanded: ownership triage, mechanical-setter and prop-drilling anti-patterns, single-consumer getters, dependency snapshots and conditional subscriptions, presence boundaries, restrained
is, subcomponent overuse, extract-then-consolidate, review checklist - react/style.md - style profile: snapshot formatting, affirmative conditions, render fallthrough vs operational guards
Model (core)
- state/state.md - Model class, instantiation, properties, methods, events, context
- state/get.md - Instance
.get()method: read values, run effects, context lookup - state/set.md - Instance
.set()method: write values, listen to updates, events, destroy - state/computed.md - Reactive class getters: tracking, caching, inheritance, suspense, when a derivation should stay local
- state/lifecycle.md - Construction, activation, operation, destruction phases; hot patching and the
hotseam - state/context.md - Context system, global root, home context, ownership rules
- state/types.md - TypeScript type aliases and utility types
Instructions: field/*.md, linked from the helper table.
React
- react/react.md - use(), Model.use(), Model.get() (optional lookup, required values
get(true), computed selector), providing state, transparent writes, ForceRefresh, Vite hot reload - react/component.md - Component class, props, children, extending a base, subcomponent extension points, error boundaries
- react/patterns.md - Recipes: forms, async, domain-row and form-chip pools, region controls, router bridge, host-agnostic domain model + view adapter, presence boundary, contextual children, debounce, async effects, awaiting a pending value, effects
JSX
- dom/dom.md - client DOM render/unmount, stateless FC scopes, MVC snapshots, native events, portals, lazy boundaries, transitions, Vite hot reload, exclusions
Router
- router/router.md - route declarations, lexical matching, guards, Suspense, navigation state, and UI
- router/production.md - choose
RoutervsBrowserRouter, page-data ownership, navigation settlement, testing, host boundaries, and unsupported browser behavior
Inspect
- inspect.md -
@expressive/inspect: install entry, instances in process vs addresses across a boundary, ownership, stored-value reads withabsent, labels under minification, frame journal withcause,act, orphans andhealth(), bridge and Vite relay
Examples
- examples/basic.md - Complete working examples from simple to intermediate
- examples/audit.md - Guide for evaluating whether Expressive MVC fits a codebase
Runnable examples - optional; this skill is complete offline. https://expressive.dev/examples/<group>/<name> serves a working program's every source file as plain HTML (no JS needed). Fetch one for a full reference implementation rather than a snippet:
| Group | Pages |
|---|---|
featured | forms, kanban, spreadsheet, stopwatch, store, tictactoe, todomvc |
essentials | counter, computed, fetch, async |
component | props, subcomponents, lifecycle, injection, headless, suspense, boundary, custom |
composition | nested, context, concerns, extension, globals |
instructions | set, set-factory, set-computed, get, get-downstream, ref, ref-multiple, map, map-insert, has, has-list, def |
router | overview, browser, params, query, guards, transitions, nav, wizard |
Auditing & Evaluation
Audit a conversion as a separate pass over the diff - self-audits while authoring under-report architecture gaps.
Fit and migration candidates: examples/audit.md; approved migrations follow react/refactor.md. Positioning: website-only why, comparisons, and dom vs React.
评价
还没有评价。欢迎分享你的使用感受。
评论
无需登录即可发表。欢迎分享使用感受、提问,或说明在哪些环境下无法运行。
还没有评论。来写第一条吧。