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
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Core concepts
  2. Anatomy of a primitive

Anatomy of a primitive#

The five primitives — state, query, mutation, queryParams, asyncProcess — share one shape. Learn it once here; each primitive's page then only covers what is specific to it.

The shape#

typescript
primitive(name, config, insertion?);
  • name — always first, always a string literal.

  • config — what the primitive needs to do its job (an initial value, a loader, a codec map…). This is the part that differs between primitives.

  • insertion — optional, adds methods and computed values to the result.

Naming is not decoration#

The name tags the primitive's injector — state:tasks, query:userQuery — and is what identifies it in logs, snapshots and the observability tooling. Two primitives with the same name in the same scope are two different things wearing one label, and the tooling cannot tell them apart.

Driving it with yield*#

A primitive does not run itself. Inside any generator host — a craftComponent function, a craftService factory, a craftComputed, a craftMethod, craftGen, a route helper — yield* is the driver. The entity that yields records the dependency on its graph:

typescript
const tasks = yield* state('tasks', []);

yield* also folds whatever the primitive depends on into the enclosing dependency tree, which is what the route DI check and the test registers read.

It resolves to the primitive reference#

Every named primitive returns its reference directly:

typescript
const tasks = yield* state('tasks', []);

Inside a craftService, the name is also the key the primitive is exposed under: the service's API is every named primitive its factory yields.

typescript
const { UserQuery } = craftService(
  { name: 'UserQueryWithState', providedIn: 'function' },
  function* (inputs: { userId: CraftServiceInput<string | undefined> }) {
    yield* query('userQuery', {
      params: function* () {
        return yield* inputs.userId();
      },
      loader: ({ params }) => ApiService.getItemById(params),
    });
    // Created and tracked, but not exposed.
    yield* craftPrivate(state('refresh', 0));
  },
);

// const { userQuery } = yield* UserQuery({ userId });

See craftService.

Insertions add to the result#

The last argument receives the primitive's internals and returns what to expose:

typescript
state('counter', 0, ({ state, update }) => ({
  increment: () => update((value) => value + 1),
  isEven: craftUse(craftComputed('isEven', function* () {
    return (yield* state()) % 2 === 0;
  })),
}));

Compose several with the primitive-specific helpers described in Typed insertion pipes. An insertion can also be a function*, in which case it can yield* services. A derived value or generator method must yield readers it does not own — including this primitive's state() / update() when the member is a generator. Keep craftPipe for universal or nested compositions.

Scoped providers#

Every primitive config accepts providers, for dependencies that should be scoped to this primitive alone rather than to the whole service:

typescript
query('userQuery', {
  providers: [provideUserApiService()],
  loader: function* () {
    return yield* UserApiService.get();
  },
});

Injectable runtime context#

Everyday insertions already receive set, update, and patch as arguments. Keep using that.

Each primitive also provides those same writes through Craft DI on every insertion method. Wrappers, registries, tests, WebMCP tools, and other advanced patterns can recover them without being passed the insertion context — for example to seed a query result, patch a mutation value, or drive a state from a provideFnWrapper.

That is also the surface a WebMCP client uses to inspect and mutate a live primitive: get / set / update / patch on a query result, a state, a mutation, an asyncProcess, or queryParams, without editing TypeScript or reloading the page.

The helpers return undefined outside an insertion-method injection context. Use the one that matches the primitive, or the generic helper and branch on kind:

PrimitiveHelper
stateinjectStateMethodRuntimeContext()
queryinjectQueryMethodRuntimeContext()
mutationinjectMutationMethodRuntimeContext()
queryParamsinjectQueryParamsMethodRuntimeContext()
asyncProcessinjectAsyncProcessMethodRuntimeContext()
any of theminjectPrimitiveMethodRuntimeContext()

The context is the same shape everywhere:

typescript
{
  kind: 'state' | 'query' | 'mutation' | 'queryParams' | 'asyncProcess';
  get(): unknown;
  set(value: unknown): unknown;
  update(updater: (current: unknown) => unknown): unknown;
  patch(updater: (current: unknown) => object): unknown;
  originalSource: string;
}

patch merges objects. Use update to replace arrays or primitives. Nested insertSelect methods receive the selected slice, not the root.

typescript
import {
  injectQueryMethodRuntimeContext,
  provideFnWrapper,
} from '@craft-ts/core';

provideFnWrapper(
  'Warning: dependency injection here is not type-safe and may fail at runtime',
  function* (factory, thisArg, args) {
    const query = injectQueryMethodRuntimeContext();
    const result = yield* factory.apply(thisArg, args);
    query?.patch((current) => ({ ...current, viewed: true }));
    return result;
  },
);

query, mutation, asyncProcess, and queryParams also publish the primitive value itself — not only its methods — through providePrimitiveResourceRuntimeObserver. Register it on the primitive's providers (or higher). The observer runs at creation; keep the context if you need to write later. Grouped resources take an optional id equivalent to .select(id). state has no resource observer: only the method context.

typescript
import {
  providePrimitiveResourceRuntimeObserver,
  query,
  type PrimitiveResourceRuntimeContext,
} from '@craft-ts/core';

let usersRuntime: PrimitiveResourceRuntimeContext | undefined;

const users = yield* query('users', {
  providers: [
    providePrimitiveResourceRuntimeObserver((context) => {
      if (context.kind === 'query') {
        usersRuntime = context;
      }
    }),
  ],
  params: () => true,
  loader: function* () {
    return yield* UserApi.list();
  },
});

usersRuntime?.set([{ id: 'stub', name: 'Preview' }]);

The internal token behind these helpers is not part of the public API. Use the helpers; do not look up the token yourself.

Reading a value that may have failed#

The async primitives (query, mutation, asyncProcess) expose one value reader:

  • value() — never throws, returns undefined when no value is available.

TIP

Pass query.value to a template binding. Inside a generator, yield* query.value().

Pitfalls#

A primitive invocation is single-use. Each call produces one generator, to be consumed exactly once. Storing one and yield*-ing it twice does not give you two primitives — it fails.

It must run in an injection context. A field initialiser, a constructor, a craft factory. Called outside one, a primitive returns only its configuration under _config instead of a live ref — which usually surfaces later as a confusing "not a function" error.

Methods bound to a source with on$ are not exposed on the result. They work internally, driven by the source, and do not appear on the ref.

Don't inject the runtime context from feature insertions. The insertion already receives typed set / update / patch as arguments. The injectable helpers are untyped and exist for wrappers, registries, WebMCP tools, and other advanced patterns.

See Also#

  • Which primitive should I use?

  • Insertions

  • Generators and yield*

  • Observability — provideFnWrapper as a consumer of the runtime context

←Which primitive should I use?Generators and yield*→

On this page

  • The shape
  • Naming is not decoration
  • Driving it with yield*
  • It resolves to the primitive reference
  • Insertions add to the result
  • Scoped providers
  • Injectable runtime context
  • Reading a value that may have failed
  • Pitfalls
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search