Skip to content

1. Your first state ​

Goal: get a reactive value on screen, and meet the two building blocks you will use in every step — craftComponent and a primitive.

Install ​

shell
npm i @craft-ts/core@beta @craft-ts/component@beta
npm i -D @craft-ts/dev-tools@beta

The packages are currently published on the beta channel. The component package contains the functional renderer, while core contains the reactive primitives used by the component factory.

A component with state ​

A Craft component is a function, not a class. It takes a name, meta, a logic factory, and a template:

ts
import { craftComponent, forNode, h1, li, ul } from '@craft-ts/component';
import { state } from '@craft-ts/core';

type Task = { id: string; title: string; done: boolean };

export const Tasks = craftComponent(
  'Tasks', // name: stable component name used by tooling and host tags
  {}, // meta: providers, styles and host configuration
  function* () {
    // logic factory: creates the component context
    const tasks = yield* state('tasks', [
      // name: state identifier
      { id: '1', title: 'Read step 1', done: false },
    ] as Task[]); // initial value: the seeded task list

    return { tasks };
  },
  ({ tasks }) => [
    // template: turns the context into rendered nodes
    h1('Tasks'),
    ul(
      forNode(
        tasks, // source: the reactive collection to render
        { track: (task) => task.id }, // options: stable identity for each item
        // render: creates one node per task
        (task) =>
          li(function* () {
            return (yield* task()).title;
          }),
      ),
    ),
  ],
);

Four arguments, and each has one job:

ArgumentWhat it is
'Tasks'the component's name — used by the tooling and by host tags
{}meta: providers, styles, host properties (empty for now)
function*the logic factory — builds and returns the context
({ … }) =>the template — receives that context, returns nodes

There is no class, no decorator, no separate HTML file, and no host element wrapped around your markup.

Inputs and outputs ​

A component's inputs and outputs are just parameters of the logic factory, typed with Input<T> and Output<Handler>:

ts
import {
  Input,
  Output,
  button,
  craftComponent,
  div,
  span,
} from '@craft-ts/component';
import { deepYieldable } from '@craft-ts/core';

type User = { name: string };

const UserCard = craftComponent(
  'UserCard',
  {},
  (user: Input<User>, onRemove: Output<(user: User) => void>) => ({
    user: deepYieldable(user),
    onRemove,
  }),
  ({ user, onRemove }) =>
    div([
      span(user.name),
      button(
        'remove',
        {
          type: 'button',
          *click() {
            yield* onRemove(yield* user());
          },
        },
        'Remove',
      ),
    ]),
);

An Input<T> is a yieldable reader — yield* user() reads the current value. Project nested fields with deepYieldable so user.name stays a reader. An Output<H> is a yieldable callback; delegate to it with yield*.

At the call site you pass the reader itself, not a getter:

typescript
UserCard({
  user: currentUser,
  onRemove: removeUser,
});
ContractCraft
Inputan Input<T> factory parameter
Outputan Output<H> parameter, called directly
Component callUserCard({ user: u, onRemove: fn })
Missing required inputcompile error

Because it's a function call, there is no template-binding layer between caller and component: a wrong input name or type is a plain TypeScript error.

Styling the component ​

Styles go in a sheet beside the component — tasks.style.ts — written with @craft-ts/style:

typescript
import {
  craftStyles,
  defineStateAxis,
  display,
  gap,
  space,
  textDecorationLine,
  when,
} from '@craft-ts/style';

export const taskState = defineStateAxis('task', ['done']);

export const tasks = craftStyles('tasks', {
  root: [display.grid, gap(space(2))],
  item: [when(taskState.done, [textDecorationLine.lineThrough])],
});

The template binds one constant class per element and says which state it is in with an attribute — li({ class: tasks.item, 'data-task': 'done' }, …). A class is never built at render time: that is how every visual state stays listed in the sheet. The build emits the CSS once; nothing is injected when the component mounts. See Styling a component.

Mounting the root ​

The app's root is a Craft component too. provideCraftRootComponent(App) designates it, and the Craft host bootstraps the application:

typescript
// app.config.ts
export const appConfig = craftAppConfig({
  providers: [provideCraftRootComponent(App)],
});
typescript
// main.ts
import { bootstrapCraft } from '@craft-ts/component';
import { appConfig } from './app/app.config';

bootstrapCraft({ config: appConfig });

bootstrapCraft builds the root injector, runs the app-start hooks, then mounts the root component into <craft-root>.

The two rules of a primitive ​

1. A primitive is named. state('tasks', …) — the first argument is always the name. It is not decoration: it tags the primitive's injector (state:tasks) and is what identifies this piece of state in logs, snapshots and observability.

2. It resolves to the state reference itself:

typescript
const tasks = yield * state('tasks', []);

tasks is a yieldable reader: yield* tasks() in a generator, craftUse(tasks()) at a synchronous boundary, or pass tasks directly to a template binding.

What is yield* doing there? ​

The factory is a generator, and yield* is how this factory drives everything it does not own — primitives and services alike. The same rule applies later to every computed and method: each entity yields its own dependencies so they show up on its graph.

For now, treat it as "the way to use a primitive inside a factory". Step 4 explains what it buys you.

The template ​

The template is a plain function returning nodes built with hyperscript helpers — div, ul, li, button, and one h(tag, …) escape hatch for anything without a helper:

typescript
({ tasks }) => [
  h1('Tasks'),
  ul(
    forNode(tasks, { track: (task) => task.id }, (task) => li(task.title)),
  ),
];

Pass the reader (tasks) to the binding that consumes it. The renderer drives the read; wrapping () => tasks() is a synchronous call the yield rules reject. Use forNode(...) when the collection controls a node per item. No *ngFor, no change detection to think about.

Writing to it ​

Right now the state is read-only from the outside. Give it a writer:

typescript
const tasks = yield* state('tasks', [] as Task[], ({ set }) => ({ set }));

yield* tasks.set([{ id: '1', title: 'Write step 2', done: false }]);

That third argument is an insertion — the mechanism you'll use in every step from here on. Step 2 is entirely about it.

What you gained ​

A component and a reactive value, both declared as functions, both named, both visible to the tooling — with no class, no constructor and no subscription.