Expressive MVC
Guides

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 a Model.

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 to undefined - 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:

PropTypeDescription
is(instance: T) => Model.Assign<T> | voidCalled once with the created instance; a returned object is assigned to it
refReact ref (object or callback)Standard ref - receives the instance after mount, null on unmount
fallbackReactNode | falseShown 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 />} />

is runs after props apply but before the new() lifecycle hook - so it can configure state that new() then observes. Use ref for post-mount imperative access, is when 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 title re-renders only Header, not Sidebar.
  • 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.


Loading sandbox...

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.fallback inside catch() shows error UI while recovery is pending. After catch() resolves, the fallback reverts and render() retries. A sync catch() 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 readonly key property 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>, the get instruction, and downstream collection.
  • API: Component - every Component method and prop.

On this page