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-ng/core@beta @craft-ng/component@beta
npm i -D @craft-ng/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, each, h1, li, ul } from '@craft-ng/component';
import { state } from '@craft-ng/core';
type Task = { id: string; title: string; done: boolean };
export const Tasks = craftComponent(
'Tasks',
{},
function* () {
const tasks = yield* state('tasks', [ // read yield* as "I need"
{ id: '1', title: 'Read step 1', done: false },
] as Task[]);
return { tasks };
},
({ tasks }) => [
h1('Tasks'),
ul(each(tasks, { track: (task) => task.id }, (task) => li(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-ng/component';
import { deepYieldable } from '@craft-ng/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({
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,
});| Angular | Craft |
|---|---|
@Input() / input() / input.required() | a Input<T> factory parameter |
@Output() / output() + .emit(...) | an Output<H> parameter, called directly |
[user]="u" / (remove)="fn($event)" | UserCard({ user: u, onRemove: fn }) |
| Missing required input → runtime | missing parameter → 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 the meta, and :scope is the component's own root:
craftComponent(
'Tasks',
{
styles: `
:scope { display: grid; gap: .5rem }
.done { text-decoration: line-through }
`,
},
/* … */
);:scope refers to the root of this component. Component styles are scoped with CSS @scope, so the rule cannot leak into unrelated components — and Craft adds no host element or wrapper around your markup to achieve it. See Encapsulated styles.
Mounting the root
The app's root is a Craft component too. provideCraftRootComponent(App) designates it, and Angular bootstraps a thin host:
// app.config.ts
export const appConfig = craftAppConfig({
providers: [provideCraftRootComponent(App)],
});// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { CraftRootComponentHost } from '@craft-ng/component';
import { toApplicationConfig } from '@craft-ng/core';
import { appConfig } from './app/app.config';
bootstrapApplication(CraftRootComponentHost, toApplicationConfig(appConfig));toApplicationConfig turns the craft config into the ApplicationConfig Angular expects, so the rest of your Angular setup is unchanged.
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.
Coming from Angular classes?
In an Angular @Component class there is no generator to yield from, so you drive a primitive with craftUse(state('tasks', [])) instead. Same primitive, same result — see Anatomy of a primitive.
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(
each(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 each(...) 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.