Expressive MVC

Getting Started

Install, write your first state class, and learn how the pieces fit together

Install

npm install @expressive/react

That's the only package a React app needs - it includes the framework-agnostic core and the React adapter.

import State from '@expressive/react';

State is the default export. Instructions and utilities are named exports:

import State, { Component, Provider, get, set, ref } from '@expressive/react';

Working with an agent? npx skills add gabeklein/expressive-mvc teaches it the whole API in one shot.


Your first state

Ultimately, the workflow is pretty simple.

  1. Create a class. Fill it with the values, getters, and methods you'll need.
  2. Extend State (or any derivative, for that matter), making it reactive.
  3. Within a component, call use() as you would any other hook.
  4. Destructure out the values the component needs, subscribing to them.
  5. Update those values on demand. Your component will sync automagically. ✨

Start with a bare minimum:

class Counter extends State {
  count = 0;

  increment() {
    this.count++;
  }

  decrement() {
    this.count--;
  }
}

Then use it inside a React component:

function CounterView() {
  const { count, increment, decrement } = Counter.use();

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

Three things are happening here:

  1. Counter.use() creates an instance scoped to the component - constructed on mount, destroyed on unmount.
  2. Destructuring subscribes. The component grabbed count, so when count changes, this component (and only this component) re-renders. Values it ignores are ignored right back.
  3. Methods come pre-bound. increment and decrement can be passed straight to event handlers - this is always correct, no useCallback in sight.

Here's that same idea running for real - edit it if you like:

Loading sandbox...

A more complete example

A todo list with a computed value, local persistence, and a dash of validation - all in one class:

import State from '@expressive/react';

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

class TodoApp extends State {
  items: Todo[] = [];
  draft = '';
  nextId = 1;

  get remaining() {
    return this.items.filter((i) => !i.done).length;
  }

  new() {
    const saved = localStorage.getItem('todos');
    if (saved) this.items = JSON.parse(saved);

    return this.set('items', () => {
      localStorage.setItem('todos', JSON.stringify(this.items));
    });
  }

  add() {
    const text = this.draft.trim();
    if (!text) return;
    this.items = [...this.items, { id: this.nextId++, text, done: false }];
    this.draft = '';
  }

  toggle(id: number) {
    this.items = this.items.map((i) =>
      i.id === id ? { ...i, done: !i.done } : i
    );
  }
}

Then the view:

function TodoList() {
  const { is: todo, items, draft, remaining, add, toggle } = TodoApp.use();

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          add();
        }}>
        <input
          value={draft}
          onChange={(e) => (todo.draft = e.target.value)}
          placeholder="What needs doing?"
        />
        <button type="submit">Add</button>
      </form>
      <p>{remaining} remaining</p>
      <ul>
        {items.map((item) => (
          <li key={item.id} onClick={() => toggle(item.id)}>
            {item.done ? <s>{item.text}</s> : item.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

Notice what the component doesn't have: no effects, no memoization, no derived state in the render body, no setup. It's a pure projection of the state - new() handles persistence, remaining keeps itself current, and set('items', ...) runs whenever the list changes.


The is property

After destructuring, you lose write access - count = 5 just rebinds a local variable. For this, every State exposes is, a loop-back to the instance itself. Prefer destructuring it first, aliased to the state's concept:

const { is: todo, draft } = TodoApp.use();
todo.draft = 'hello'; // updates state and re-renders

is has a second trick: silent reads. Reading through is inside a tracking context does not subscribe - handy when you want a value without volunteering to re-render every time it changes. More on that in Reactivity.


The mental model

If you've used React hooks, you're used to state living inside components. Expressive flips this: state lives in a class, and components are thin projections of it.

// The brain - data, derived values, and behavior
class SearchPage extends State {
  query = '';
  results: string[] = [];
  loading = false;

  async search() {
    this.loading = true;
    const res = await fetch(`/api/search?q=${this.query}`);
    this.results = await res.json();
    this.loading = false;
  }
}

// The face - just renders what the brain knows
function SearchView() {
  const { is: page, query, results, loading, search } = SearchPage.use();

  return (
    <div>
      <input
        value={query}
        onChange={(e) => (page.query = e.target.value)}
        onKeyDown={(e) => e.key === 'Enter' && search()}
      />
      {loading ? (
        <p>Searching...</p>
      ) : (
        <ul>
          {results.map((r) => (
            <li key={r}>{r}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Because state is a portable object, we can modify it from anywhere - and more crucially, whenever. That makes the async stuff pretty low maintenance: you write the business logic, the class handles dispatch. It also means:

  • Testability - SearchPage.new() outside of React, call methods, assert results. No renderer required.
  • Reusability - one State can power multiple views (sidebar, modal, full page).
  • TypeScript - the class is the type. Every destructure is fully inferred.
  • Navigability - a feature's data and behavior live in one file, not smeared across several hook calls.

When to create a State class

You don't need a class for every component. Good signals you should reach for one:

  • Three or more related pieces of state.
  • An effect that syncs two or more state values.
  • A handler that touches more than one state value.
  • State shared between components.
  • A custom hook that returns an object.
  • Async logic - fetching, polling, websockets.
  • Props being passed down mostly to support state sharing.

For single booleans, form field focus, or throwaway UI state, useState is still the right tool. Expressive is for the cases where state has behavior.

When it does, choose the class shape by ownership:

  • Component when the state is intrinsic to one rendered unit - a shell, tab panel, form control, media player, editor surface.
  • State when the state is display-agnostic, shared across components, or worth testing as a model without rendering.

Don't reach for Component just because a class is contextual - State handles headless controllers fine, and child States of anything provided are available to descendants automatically.

And avoid splitting into FooState plus FooView by default. If the behavior only exists for Foo's own UI, class Foo extends Component is usually clearer.


Next steps

  • Why Classes? - the organizational case for moving state out of components.
  • State Classes - fields, methods, and lifecycle.
  • Reactivity - how tracking, computed values, and batching work.
  • Components - the Component class, which lets state render itself.

On this page