Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources

i.Learn

  • Overview
  • 1. Your first state
  • 2. Derive instead of duplicate
  • 3. Move logic out of the component
  • 4. Compose services
  • 5. Load server data
  • 6. Write server data
  • 7. Put state in the URL
  • 8. Build a form
  • 9. Wire up routing
  • 10. Test what you wrote
  • Where to go next
  1. Learn
  2. 1. Your first state

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 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:

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* () {
    // 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:

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 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>:

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',
  {},
  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:

typescript
UserCard({
  user: currentUser,
  onRemove: removeUser,
});
ContractCraft
Inputan Input<T> member of the inputs object
Outputan Output<H> member, 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 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:

typescript
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:

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.

← Overview

2. Derive instead of duplicate →

←Overview2. Derive instead of duplicate→

On this page

  • Install
  • A component with state
  • Inputs and outputs
  • Styling the component
  • Mounting the root
  • The two rules of a primitive
  • What is yield* doing there?
  • What it returns
  • Writing to it
  • What you gained

@craft-ts · MIT licensed

GitHubnpm

Search