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@beta
The packages are currently published on the beta channel. The component package contains the functional renderer, while core contains the reactive primitives a component declares.
A component with state#
A Craft component is a function, not a class. It takes a name, meta, and the function that declares what it owns and returns what it renders:
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* () {
// one function: it declares what the component owns, then returns nodes
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 [
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;
}),
),
),
];
},
);
Three 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 component itself — it declares, then returns nodes |
One function, read top to bottom: what the component takes (yield*), then what it renders (return). 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 the members of its single parameter, 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',
{},
function* (inputs: {
readonly user: Input<User>;
readonly onRemove: Output<(user: User) => void>;
}) {
const user = deepYieldable(inputs.user);
const { onRemove } = inputs;
return div([
span(user.name),
button(
'remove',
{
type: 'button',
*click() {
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 plain callback: call it.
At the call site you pass the reader itself, not a getter:
UserCard({
user: currentUser,
onRemove: removeUser,
});
| Contract | Craft |
|---|---|
| Input | an Input<T> member of the inputs object |
| Output | an Output<H> member, 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 component is a generator, and yield* is how it 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 component". Step 4 explains what it buys you.
What it returns#
The component returns nodes built with hyperscript helpers — div, ul, li, button, and one h(tag, …) escape hatch for anything without a helper:
return [
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.