Expressive MVC
Guides

Components

Smart, reusable components that own their behavior and rendering

Function components work best when they're dumb - receive data, render UI, done. When you need a smart component that owns its behavior, lifecycle, and rendering as a single reusable unit, that's what Component is for.

A Component is a persistent class instance that doubles as a React component. It bundles state, methods, lifecycle, context, suspense, and error handling into one extensible class - what would otherwise be 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 accessed via this in render() are reactive - changes trigger re-renders automatically.


When to reach for Component

Use State + .use() when you want headless state 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 - when the thing you're building is a component, not data that happens to be displayed. Think: form controls, media players, animated canvases, data grids, layout shells.

Usually a Component defines render(). Without one, children pass through while the instance provides context and Suspense/ErrorBoundary placement. Use that headless form only for route controllers, progressive Boundary wrappers, or other classes whose value comes from repeated placement in the React tree.

Use State for headless models/controllers, even if they are only useful in context. A provided State or Component implicitly provides its child States, so root state can own public theme = new Theme() without another Provider.

This matters during refactors. A hook-heavy route shell, local router, tab panel, menu, editor surface, or form control does not need to become FooState plus FooView. When the behavior and rendering move together, make the rendered thing the class.

The real power is reusability through inheritance. You build a Component once - with all the lifecycle, reactivity, and error handling baked in - then your team extends and customizes it. No hook wiring, no boilerplate. Just subclass and fill in the blanks.


Custom primitives via inheritance

This is the primary use case for Component: building reusable 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 create 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 class owns the toggle logic and structure. Subclasses just define what each state looks like. Both work immediately as <DarkModeSwitch /> or <Accordion title="FAQ">...</Accordion>.

This is how teams DRY up complex 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 the outer layer because it sits higher on the chain; Page is the inner content. Add a third level and it nests the same way - each subclass becomes the children of its parent:

class Card extends Frame {
  render(props = {} as { children?: ReactNode }) {
    return <article className="card">{props.children}</article>;
  }
}

class Note extends Card {
  render() {
    return <p>note body</p>;
  }
}
// Frame > Card > <p>note body</p>

Every layer binds to the same live instance, so each render reads the same reactive this. A change to any field re-renders the whole composed output.

The base provides the default shell

This is what makes a base primitive useful: it owns the surrounding structure once, and subclasses fill the middle without restating it. A Boundary base can wrap every subclass in shared chrome, suspense, or context - subclasses only author the content.


Footgun: dropping children

The inner content arrives as props.children. A wrapper render that never reads props.children silently discards everything below it:

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. If a base means to wrap subclasses, it 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/useEffect, inside render(). But hooks aren't forbidden, and there's a sharp edge worth knowing.

The whole composed chain runs inside a single host render - every layer executes in the same component call, so all layers' hooks stack into one component. That's fine as long as the hooks obey the rules of hooks for the chain as a whole: same hooks, same order, every render.

The trap is conditional children. A hook in a layer below a wrapper that conditionally renders props.children runs only sometimes - the inner layer mounts and unmounts across renders, 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, immune to what wrappers above it do - make it a subcomponent (a PascalCase method) and render <this.Panel />. Subcomponents are each wrapped in their own component; render layers are deliberately folded into one.


Persistent identity

Component instances survive across renders. this is stable - you can store references, pass this to external objects, and hold imperative state (Sets, Maps, WebSocket connections) without losing it 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 a stable 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.

Extra render props

If you need 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
fallbackReactNodeShown while suspended or during error recovery
<Counter is={(c) => console.log('created', c)} fallback={<Loading />} />

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.

Do not use Component only because something is contextual. Components inherit React-facing properties (props, state, context, setState, forceUpdate), which can make .get() IntelliSense noisier. If the class is just a headless model, use State and provide it directly or as a child of an already-provided owner.


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 in subclasses - this is what makes the Toggle example above work.

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.


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, 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.


Lifecycle

Components inherit the full State lifecycle:

  • new() - called once after initialization. Return a cleanup function for unmount teardown.
  • use() - called every render. Use for bridging external React hooks.
  • 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>;
  }
}

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