Components
Smart, reusable components that own their behavior and rendering
Function components work best dumb - take data, render UI, done. Component is for when the thing you're building is a component. It's a persistent class instance that doubles as a React component: state, methods, lifecycle, context, suspense, and error handling in one extensible class, instead of a complicated arrangement of hooks wired together for a single purpose.
import { Component } from '@expressive/mvc';
class Counter extends Component {
count = 0;
increment() {
this.count++;
}
render() {
return <button onClick={this.increment}>{this.count}</button>;
}
}
<Counter />;Properties read via this in render() are tracked. When one changes, the component refreshes - no hooks required, or even used. Try it live.
When to reach for Component
Use Component when state is intrinsic to display logic. Form inputs, media players, data grids, app and route entrypoints - things where behavior and rendering move together. A hook-heavy route shell or tab panel doesn't need to become FooState plus FooView; when the rendered thing owns the behavior, make the rendered thing the class.
Use a Model when you want headless logic separated from rendering - data shared by several views, or a domain object worth testing on its own. Place it with <Component for>, or make it a field of the Component that owns it (page = new Pagination()); function components below read it with .get(). Either way the function components stay dumb, and the classes handle the smarts.
Usually a Component defines render(). Without one, children pass through while the instance still provides context, runs its effects at commit, and places its Suspense and error boundaries - useful for route controls, progressive Boundary wrappers, and effect-only ancestors like a focus trap. Don't write render() { return this.props.children; } - just omit it.
Good to know:
Don't use Component just because something is contextual. Components carry React-facing properties (props,state,setState...), which makes.get()IntelliSense noisier. Headless state should stay in aModel.
The real payoff is reusability through inheritance. You build a Component once - lifecycle, reactivity, and error handling baked in - then extend and fill in the blanks.
Custom primitives via inheritance
This is the primary use case: base classes for others to extend.
abstract class Toggle extends Component {
active = false;
toggle() {
this.active = !this.active;
}
Active(): ReactNode {
return null;
}
Inactive(): ReactNode {
return null;
}
render() {
return (
<div onClick={this.toggle}>
{this.active ? <this.Active /> : <this.Inactive />}
</div>
);
}
}Now anyone can make a toggle-based component without reimplementing the behavior:
class DarkModeSwitch extends Toggle {
Active() {
return <span>Dark</span>;
}
Inactive() {
return <span>Light</span>;
}
}
class Accordion extends Toggle {
title = 'Details';
Inactive() {
return <h3>{this.title}</h3>;
}
Active() {
return (
<>
<h3>{this.title}</h3>
<div>{this.props.children}</div>
</>
);
}
}The base owns the toggle logic and structure; subclasses just say what each state looks like. Both work immediately as
<DarkModeSwitch />or<Accordion title="FAQ">...</Accordion>. This is how teams DRY up UI patterns into organizational primitives - build once, extend everywhere.
A subclass render() replaces the base's like any method override; call super.render(props) to wrap it instead.
Persistent identity
Component instances survive across renders. this is stable - store references, pass it to external objects, hold imperative state (Sets, Maps, WebSocket connections) without losing anything between renders.
class ChatRoom extends Component {
messages: Message[] = [];
url = '';
protected new() {
this.get(() => {
const socket = new WebSocket(this.url);
socket.onmessage = (e) => {
this.messages = [...this.messages, JSON.parse(e.data)];
};
return () => socket.close();
});
}
render() {
return (
<ul>
{this.messages.map((m) => (
<li key={m.id}>{m.text}</li>
))}
</ul>
);
}
}No stale closures, no dependency arrays - just an object with methods. 🏝
Sockets, timers and listeners belong in an effect declared in new(), which first runs once the element commits, client-only. The new() body also runs during server render, where its cleanup never does.
Props
Model fields become optional JSX props automatically. TypeScript infers the type from the class fields - no separate interface needed.
class Greeting extends Component {
name = 'World';
render() {
return <h1>Hello, {this.name}!</h1>;
}
}
<Greeting name="React" />;Props are applied to the instance on every render.
Good to know:
Omission clears. A state prop passed on one render then left off the next is reset toundefined- props behave declaratively, like real JSX attributes, not like patches that stick around. Unmanaged_fields passed as props work the same way.
Extra render props
For props that aren't state fields, declare them via the render() parameter:
class Card extends Component {
title = '';
render(props = {} as { className: string }) {
return <div className={props.className}>{this.title}</div>;
}
}
<Card title="Hello" className="card" />;The = {} as T default is required for TypeScript's JSX attribute inference. Required fields in the parameter become required JSX attributes. All props are available via this.props.
Special props
Every Component accepts these regardless of state fields:
| Prop | Type | Description |
|---|---|---|
is | (instance: T) => Model.Assign<T> | void | Called once with the created instance; a returned object is assigned to it |
ref | React ref (object or callback) | Standard ref - receives the instance after mount, null on unmount |
fallback | ReactNode | false | Shown while suspended or during error recovery; false opts out |
catch | (error, instance) => void | Promise<void> | Handles errors from this element's content, in place of the class's catch() |
<Counter is={(c) => console.log('created', c)} fallback={<Loading />} />
isruns after props apply but before thenew()lifecycle hook - so it can configure state thatnew()then observes. Usereffor post-mount imperative access,iswhen you need the instance during construction.
Children and context
Component instances are automatically provided to React context. Without an explicit render(), children pass through a context provider.
class Layout extends Component {
theme = 'light';
}
<Layout theme="dark">
<Header />
<Main />
</Layout>;function Header() {
const { theme } = Layout.get();
return <header className={theme}>...</header>;
}This is why Component is a natural fit for app shells and feature containers - the container is the context. To provide a headless Model without writing a subclass, the bare class takes it as for: <Component for={Theme}> - see Context.
It works in both directions. A Component can consume upstream context with the get instruction, same as any Model:
class ThemedButton extends Component {
theme = get(Theme);
render() {
return <button style={{ background: this.theme.color }}>Save</button>;
}
}More on this in Context, with a full example at composition/context.
Subcomponents
Any method whose name starts with a capital letter becomes a React component scoped to this:
class Dashboard extends Component {
items = ['alpha', 'beta', 'gamma'];
title = 'My Dashboard';
Header() {
return <h1>{this.title}</h1>;
}
Sidebar() {
return (
<ul>
{this.items.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
);
}
render() {
return (
<div>
<this.Header />
<this.Sidebar />
</div>
);
}
}Key behaviors:
- Each subcomponent subscribes independently. A change to
titlere-renders onlyHeader, notSidebar. - Multiple usages of the same subcomponent are independent instances.
- They accept props like any React component.
- Reachable through context:
Dashboard.get()then<dashboard.Sidebar />. - Overridable - subclasses redefine the method, or inject one as an instance field (
Sidebar = CustomSidebar). That's what makes the Toggle example above work.
Subcomponents are extension points, not a general decomposition tool. Ask whether a subclass would reasonably replace or wrap that renderer - Toggle.Active, a grid's Row, chrome a theme variant swaps. If not, pull the busy section into a plain function component calling Dashboard.get() instead; it keeps dependencies local without the override machinery. See them in action.
Suspense
Set fallback to display a placeholder while children or render() are suspended:
class DataView extends Component {
fallback = (<span>Loading...</span>);
data = set(async () => fetch('/api/data').then((r) => r.json()));
render() {
return <pre>{JSON.stringify(this.data, null, 2)}</pre>;
}
}The JSX fallback prop overrides the class property for that instance. Set fallback to false (prop or property) to opt out of the component's own boundary entirely - suspension then bubbles to the nearest ancestor.
Opting out only works when the pending value is owned above the boundary that catches the suspension. A boundary rebuilds the subtree it retries, so state owned below it is reconstructed on every retry and requests again - a silent infinite retry loop. See both halves side by side.
Error boundaries
Override catch() to handle errors thrown by children during render:
class SafeView extends Component {
async catch(error: Error) {
this.fallback = <span>Something went wrong</span>;
await reportError(error);
}
render() {
return <RiskyComponent />;
}
}catch() receives the thrown error and the instance: catch(error, instance).
- Setting
this.fallbackinsidecatch()shows error UI while recovery is pending. Aftercatch()resolves, the fallback reverts andrender()retries. A synccatch()retries immediately. - If
catch()rejects, the error propagates to the nearest parent boundary. - If the retry throws again, that error escapes to the parent. A later, new error is caught again.
- Without
catch(), the component has no error boundary - errors propagate.
A catch attribute handles errors for one placement, in place of the class's own catch():
<SafeView catch={(error, view) => report(error)} />The boundary covers render errors only. A component's own lifecycle and effect failures go to the class-level Model.on({ catch }) handler.
Per-feature error handling without nesting <ErrorBoundary> wrappers. See the full recovery flow at component/boundary.
Lifecycle
Components inherit the full Model lifecycle:
new()- called once after initialization, including during server render. Return a cleanup function for teardown.- An effect declared in
new()first runs once the element commits, client-only. Its returned cleanup runs on destroy. catch(error, instance)- error boundary handler.- Destruction on unmount or explicit
this.set(null).
class Timer extends Component {
elapsed = 0;
protected new() {
this.get(() => {
const id = setInterval(() => this.elapsed++, 1000);
return () => clearInterval(id);
});
}
render() {
return <span>{this.elapsed}s</span>;
}
}For an instance outside the tree, use
MyComponent.new().
Component handles React strict mode correctly - only one instance is created despite double-mounts.
Rendering an instance
An activated Component instance renders where you place it - useful when another object or collection owns its lifecycle:
const first = Item.new({ label: 'First' });
const second = Item.new({ label: 'Second' });
<>{first}</>
<>{[first, second]}</>- Each instance is keyed by
key, defaulting to its unique id. Override the readonlykeyproperty for an application-defined identity. - Placing subscribes and provides context as usual, but the placement doesn't own the instance: unmounting only detaches, and its effects are not held for the placement. The owner destroys it with
instance.set(null). - One instance may appear beneath separate parents.
Next
- Context -
<Component for>, thegetinstruction, and downstream collection. - API: Component - every Component method and prop.