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/react';

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, layout shells - 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 provides context and Suspense/error-boundary placement - useful for route controllers and progressive Boundary wrappers, but that headless form is the exception.

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 stay State.

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:

Loading sandbox...

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[] = [];
  socket: WebSocket | null = null;
  url = '';

  new() {
    this.socket = new WebSocket(this.url);
    this.socket.onmessage = (e) => {
      this.messages = [...this.messages, JSON.parse(e.data)];
    };
    return () => this.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. 🏝


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 to undefined - props behave declaratively, like real JSX attributes, not like patches that stick around.

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) => voidCalled once with the created instance
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
<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 layouts, shells, and feature containers - the container is the 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 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.

They also double as a decomposition tool: pull a busy .map or chunk of chrome out of render() into a named section. 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.


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 />;
  }
}
  • Setting this.fallback inside catch() shows error UI while recovery is pending. After catch() resolves, the fallback reverts and render() retries.
  • If catch() rejects, the error propagates to the nearest parent boundary.
  • If a child throws again after recovery, the error escapes.

Per-feature error handling without nesting <ErrorBoundary> wrappers. See the full recovery flow at essentials/boundary.


Lifecycle

Components inherit the full State lifecycle:

  • new() - called once after initialization. Return a cleanup function for unmount teardown.
  • catch(error) - error boundary handler.
  • Destruction on unmount or explicit this.set(null).
class Timer extends Component {
  elapsed = 0;

  new() {
    const id = setInterval(() => this.elapsed++, 1000);
    return () => clearInterval(id);
  }

  render() {
    return <span>{this.elapsed}s</span>;
  }
}

A use() method only matters on the MyComponent.use() hook path - a Component rendered as JSX never calls it. Don't put per-render logic there expecting <MyComponent /> to run it.

Component handles React strict mode correctly - only one instance is created despite double-mounts.


Next

  • Context - Provider, get instruction, and downstream collection.
  • API: Component - every Component method and prop.

On this page