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 State + .use() when you want headless logic separated from rendering - the most common pattern. The component stays dumb, the class handles the smarts.
Use Component when state is intrinsic to display logic. Form controls, 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.
Usually a Component defines render(). Without one, children pass through while the instance still provides context, runs mount(), and places its Suspense and error boundaries - useful for route controllers, progressive Boundary wrappers, and mount-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 models should stayState.
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.
Render composition
When a subclass overrides render(), it does not replace the base render - the two compose. Each render() up the prototype chain wraps the one below it, base-outermost, with the inner output handed down as props.children. You never call super.render().
class Frame extends Component {
render(props = {} as { children?: ReactNode }) {
return (
<section className="frame">
<header>Frame</header>
{props.children}
</section>
);
}
}
class Page extends Frame {
body = 'Hello';
render() {
return <p>{this.body}</p>;
}
}
<Page />;<Page /> renders the Frame chrome with the Page content slotted in where Frame reads props.children:
<section class="frame">
<header>Frame</header>
<p>Hello</p>
</section>Frame is outer because it sits higher on the chain. Add a third level and it nests the same way - each subclass becomes the children of its parent. Every layer binds to the same live instance, so a change to any field re-renders the whole composed output.
This is what makes a base primitive useful: it owns the surrounding chrome, suspense, or context once, and subclasses author only the content. Here it is in action:
Letting a subclass replace the base
Wrapping is the default, but a base can choose to defer to a subclass instead - handy for a leaf primitive (an <a>, an <input>) that works standalone yet should be entirely overridable. The decision belongs to the base: composition synthesizes a fresh children getter, so when a subclass authored its own render, the children the base receives is not the original this.props.children. The base checks identity and steps aside:
class Link extends Component {
to = '';
render(props = {} as { children?: ReactNode }) {
const { children } = props;
// A subclass authored its own render - defer to it.
if (children !== this.props.children) return children;
return <a href={this.to}>{children}</a>;
}
}Plain <Link to="/x">hi</Link> renders the anchor; a subclass with its own render() replaces it entirely. Render-less subclasses keep the base anchor, since their children is this.props.children. This is exactly how @expressive/router's Link stays both usable and overridable.
Footgun: dropping children
The inner content arrives as props.children. A wrapper render that never reads it silently discards everything below:
class Shell extends Component {
render() {
// No props.children - inner content is dropped.
return <div>shell only</div>;
}
}
class Lost extends Shell {
render() {
return <p>never rendered</p>; // discarded
}
}The children getter is lazy, so the dropped layer never even runs. A base meant to wrap subclasses must declare a props parameter and render props.children.
Caution: React hooks in a render layer
You normally won't reach for hooks here - reactivity comes from this, so read class fields, not useState. But hooks aren't forbidden, and there's a sharp edge worth knowing.
The whole composed chain runs inside a single host render - all layers' hooks stack into one component. That's fine as long as they obey the rules of hooks for the chain as a whole. The trap is conditional children: a hook in a layer below a wrapper that conditionally renders props.children runs only sometimes, and React throws:
class Collapsible extends Component {
open = false;
render(props = {} as { children?: ReactNode }) {
// Conditionally rendering children is fine for content...
return this.open ? <div>{props.children}</div> : null;
}
}
class Panel extends Collapsible {
render() {
const [x] = useState(0); // ...but this hook now runs only when `open` - illegal
return <p>{x}</p>;
}
}If a layer needs its own isolated scope - its own hooks, subscription, and reconciliation boundary - make it a subcomponent and render <this.Panel />. Subcomponents each get their own component; render layers are deliberately folded into one.
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 = '';
mount() {
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 mount(), which runs once when the element commits, client-only. new() also runs during server render, where its cleanup never does.
Props
State 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) => void | Called once with the created instance |
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 State 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 State:
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 State.on({ catch }) handler.
Per-feature error handling without nesting <ErrorBoundary> wrappers. See the full recovery flow at component/boundary.
Lifecycle
Components inherit the full State lifecycle:
new()- called once after initialization, including during server render. Return a cleanup function for teardown.mount()- called once when the element commits, client-only. Return a cleanup function for unmount.catch(error, instance)- error boundary handler.- Destruction on unmount or explicit
this.set(null).
class Timer extends Component {
elapsed = 0;
mount() {
const id = setInterval(() => this.elapsed++, 1000);
return () => clearInterval(id);
}
render() {
return <span>{this.elapsed}s</span>;
}
}A Component is rendered, not used -
MyComponent.use()throws. For an instance outside the tree, useMyComponent.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
mount()doesn't run. The owner destroys it withinstance.set(null). - One instance may appear beneath separate parents.
Next
- Context -
<Component for>, thegetinstruction, and downstream collection. - API: Component - every Component method and prop.