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 @solid-mvc/core solid-js
npm install -D vite-plugin-solid @babel/plugin-proposal-decorators @babel/plugin-transform-class-properties typescript vite

Decorators need two tsconfig flags; JSX stays preserve so vite-plugin-solid compiles it:

tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "jsx": "preserve",
    "jsxImportSource": "solid-js"
  }
}

And vite-plugin-solid compiles JSX with @solid-mvc/core/web as the JSX runtime module — its createComponent is what turns View classes into host components:

vite.config.ts
import solidPlugin from 'vite-plugin-solid';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    solidPlugin({
      solid: { moduleName: '@solid-mvc/core/web' },
      babel: {
        plugins: [
          ['@babel/plugin-proposal-decorators', { legacy: true }],
          ['@babel/plugin-transform-class-properties', { loose: true }],
        ],
      },
    }),
  ],
});

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 '@solid-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 '@solid-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 { For } from 'solid-js';
import { Component, View, inject } from '@solid-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>
          <For each={this.store.todos.value}>{(t) => <li>{t.text}</li>}</For>
        </ul>
      </section>
    );
  }
}

5 · The module declares it all

todo.module.ts
import { Module } from '@solid-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 '@solid-mvc/core';
import { TodoModule } from './todo.module';

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

What just happened

  • bootstrap created the app injector from TodoModule and mounted its bootstrap view inside it. JSX compiled against @solid-mvc/core/web routes View classes through createComponent into host components — Solid never constructs them directly.
  • The view's injector resolved TodoStore (declared on the module) and the declared TodoController — once, at mount. render() runs once; signal reads inside JSX expressions are what Solid tracks.

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