Quickstart

A routed todo app: one module, one store, one controller, one view. Six files, and injection replaces prop drilling from the start.

1 · Install

npm install @preact-mvc/core preact @preact/signals
npm install -D @preact/preset-vite typescript vite

Decorators need two tsconfig flags; JSX compiles to Preact's automatic runtime:

tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "jsx": "react-jsx",
    "jsxImportSource": "preact"
  }
}

useDefineForClassFields: false matters: inject() field initializers must run inside the injection context the constructor opens — class-field semantics would evaluate them too early.

2 · A store

todo.store.ts
import { Injectable, signal, computed } from '@preact-mvc/core';

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

@Injectable()
export class TodoStore {
  todos = signal<Todo[]>([]);
  remaining = computed(() => this.todos.value.filter(t => !t.done).length);

  add(text: string) {
    this.todos.value = [
      ...this.todos.value,
      { id: Date.now(), text, done: false },
    ];
  }
}

3 · A controller

todo.controller.ts
import { Controller, inject, signal } from '@preact-mvc/core';
import { TodoStore } from './todo.store';

@Controller()
export class TodoController {
  private store = inject(TodoStore);
  draft = signal('');

  submit() {
    const text = this.draft.value.trim();
    if (text) this.store.add(text);
    this.draft.value = '';
  }
}

4 · A view

todo.view.tsx
import { Component, View, inject } from '@preact-mvc/core';
import { TodoController } from './todo.controller';
import { TodoStore } from './todo.store';

@Component({ controllers: [TodoController] })
export class TodoView extends View {
  private c = inject(TodoController);
  private store = inject(TodoStore);

  render() {
    return (
      <section>
        <h2>Todos ({this.store.remaining.value} left)</h2>
        <input
          value={this.c.draft.value}
          onInput={(e) => (this.c.draft.value = e.currentTarget.value)}
        />
        <button onClick={() => this.c.submit()}>add</button>
        <ul>
          {this.store.todos.value.map((t) => (
            <li key={t.id}>{t.text}</li>
          ))}
        </ul>
      </section>
    );
  }
}

5 · The module declares it all

todo.module.ts
import { Module } from '@preact-mvc/core';
import { TodoStore } from './todo.store';
import { TodoView } from './todo.view';

@Module({
  providers: [TodoStore],
  components: [TodoView],
  bootstrap: [TodoView],
})
export class TodoModule {}

6 · Bootstrap

main.tsx
import { bootstrap } from '@preact-mvc/core';
import { TodoModule } from './todo.module';

bootstrap(TodoModule, document.getElementById('app')!);

What just happened

  • bootstrap created the app injector from TodoModule, mounted its bootstrap view inside it, and installed the JSX interop that turns View classes into host components.
  • The view's injector resolved TodoStore (declared on the module) and the declared TodoController — once, at mount. render() is plain code; signals re-render it.

Next: dependency injection for provider forms and scopes, or the router to add routes.