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
npm i @craft-ts/core@beta @craft-ts/component@beta
npm i -D @craft-ts/dev-tools@betaThe 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:
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:
| Argument | What 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>:
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:
UserCard({
user: currentUser,
onRemove: removeUser,
});| Contract | Craft |
|---|---|
| Input | an Input<T> factory parameter |
| Output | an Output<H> parameter, called directly |
| Component call | UserCard({ user: u, onRemove: fn }) |
| Missing required input | compile 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:
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:
// app.config.ts
export const appConfig = craftAppConfig({
providers: [provideCraftRootComponent(App)],
});// 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:
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:
({ 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:
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.