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. 4. Compose services

4. Compose services#

Goal: understand yield* — the one idea the whole library is built on.

This is the step that makes everything else obvious. Take your time here.

This example gives TaskList a fresh context inside each TaskStats call. For state owned by a component or route, use toProvide as in step 3 so repeated reads resolve the same configured instance.

The problem yield* solves#

Dependencies are easy to hide when a service reaches into a runtime container. Craft makes each dependency visible in the type by yielding it:

typescript
export const { TaskList } = craftService(
  { name: 'TaskList', providedIn: 'function' },
  function* () {
    const api = yield* TaskApi(); // ← tracked

    // Exposed as `tasks`: a service's API is what it yields.
    yield* state('tasks', [] as Task[], /* … */);
  },
);

Now TaskList's type carries TaskApi as a dependency. Everything downstream — the DI check on routes, the testing register, the dependency snapshot — reads that type.

Why a generator?#

A generator is just a function that can hand control back to its caller at each yield. Craft uses it as a collection channel: each yield* reports "I need this" to the runtime driving the factory, which resolves it and folds it into the graph.

You don't manage that channel yourself. In practice the whole rule is:

Every named entity yields what it does not own. A factory, a computed, a method — each one records its dependencies with yield*.

typescript
const api = yield* TaskApi(); // a service
const tasks = yield* state('tasks', []); // a primitive

A primitive is single-use

Each state(...) / query(...) call produces one generator, consumed exactly once. Don't store one and yield* it twice.

Composing two services#

typescript
const { TaskApi } = craftService(
  { name: 'TaskApi', providedIn: 'global' },
  function* () {
    // raw fetch, only to keep this example about composition —
    // see the note below
    yield* craftExpose('fetchAll', () =>
      fetch('/api/tasks').then((r) => r.json()),
    );
  },
);

const { TaskList } = craftService(
  { name: 'TaskList', providedIn: 'function' },
  function* () {
    const api = yield* TaskApi();
    yield* state('tasks', [] as Task[], ({ set }) => ({
      // For this demo only; we'll later see why this belongs in a mutation instead.
      load: function* () {
        return yield* set(yield* api.fetchAll());
      },
    }));
  },
);

const { TaskStats } = craftService(
  { name: 'TaskStats', providedIn: 'function' },
  function* () {
    const { tasks } = yield* TaskList();
    yield* craftComputed('done', function* () {
      return (yield* tasks()).filter((t) => t.done).length;
    });
  },
);

A service's factory is always a generator: its API is what it yields. A named primitive is exposed under its name; anything else — like fetchAll — is exposed with craftExpose(name, value).

TaskStats does not own TaskList. The computed yields tasks so that read is recorded on done, not silently closed over from the factory.

Don't call `fetch` directly in real code

It is used here only to keep the example about composition. HTTP goes through CraftHttpClient, which is yieldable — so the request is tracked like any other dependency, it is mockable at the browser boundary in tests, and above all it is what turns a failed response into a typed craftException you can handle.

A raw fetch gives you none of that: no tracking, no boundary, and a rejected promise instead of a declared failure. Step 5 uses CraftHttpClient for real, and step 6 shows the exceptions it produces.

The craft-ts/prefer-craft-http-client ESLint rule flags direct HttpClient usage for the same reason.

Taking only what you need#

TaskStats only reads the array. Say so, and the graph records only that:

typescript
const { TaskStats } = craftService(
  { name: 'TaskStats', providedIn: 'function' },
  function* () {
    const fetchAll = yield* TaskApi.fetchAll(); // one property
    // …
  },
);

A test for TaskStats then has to mock fetchAll and nothing else.

What you gained#

The mental model: declare with a name, drive with yield*, derive the rest. Every remaining step is a variation on it — query yields, mutation yields, guards yield, route providers yield.

Going deeper

craftGen lets you write a standalone generator outside a service — useful for guards and route helpers. See Generators.

← 3. Move logic out of the component

5. Load server data →

←3. Move logic out of the component5. Load server data→

On this page

  • The problem yield* solves
  • Why a generator?
  • Composing two services
  • Taking only what you need
  • What you gained

@craft-ts · MIT licensed

GitHubnpm

Search