Local state
state holds a value you own, in memory, as a signal — with its methods and derived values attached to it rather than scattered around it.
Use it when the value's home is your application: a form draft, a selection, a toggle, a counter. Not when the value lives on a server (query), in the URL (queryParams), or is the result of an async action (asyncProcess).
The common case
import { craftComputed, state } from '@craft-ts/core';
const counter = yield* state('counter', 0, ({ state, update, set }) => ({
increment: () => update((value) => value + 1),
decrement: () => update((value) => value - 1),
reset: () => set(0),
isEven: craftComputed(function* () {
return (yield* state()) % 2 === 0;
}),
}));
yield* counter(); // 0
yield* counter.increment();
yield* counter.isEven(); // false
yield* counter.reset();The insertion context gives you state (the current value as a yieldable reader), set and update. Non-generator methods may return update(...) directly — the insertion wrapper consumes the write. isEven yields state() because the computed does not own that reader. In a template, pass the reader or the method: p(counter), button({ click: counter.increment }, '+'). At a synchronous boundary, craftUse(counter.increment()).
New to the shape?
The name, the destructuring, the yield* driver and the single-use rule are the same for all five primitives — see Anatomy of a primitive.
Deriving from another reader
The initial value can be a Craft reader, in which case the state follows it:
const origin = yield* state('origin', 5);
const doubled = yield* state(
'doubled',
craftComputed('originDoubled', function* () {
return (yield* origin()) * 2;
}),
);
yield* doubled(); // 10Composing several insertions
One insertion function gets crowded. Split it and compose with insertStatePipe:
import { craftComputed, insertStatePipe, state } from '@craft-ts/core';
const counter = yield* state(
'counter',
0,
insertStatePipe(
({ update, set }) => ({
increment: () => update((current) => current + 1),
reset: () => set(0),
}),
({ state }) => ({
isOdd: craftComputed(function* () {
return (yield* state()) % 2 === 1;
}),
}),
),
);
yield* counter.increment();
yield* counter.isOdd(); // trueEach function receives the same context and contributes its own slice. See Insertions.
Keep transitions with their state
Pass an intent to the state that owns a value. Do not read the value in a caller, calculate a replacement there, and pass the replacement back through a generic method such as replace or update.
const todos = yield* state('todos', initialTodos, ({ update }) => ({
move: (todoId: string, direction: 'up' | 'down') => update((current) => {
const from = current.findIndex((todo) => todo.id === todoId);
const to = from + (direction === 'up' ? -1 : 1);
if (from < 0 || to < 0 || to >= current.length) return current;
const next = [...current];
const [todo] = next.splice(from, 1);
if (!todo) return current;
next.splice(to, 0, todo);
return next;
}),
}));
// The caller supplies only the intent.
yield* todos.move(todoId, 'up');The recommended and Effect ESLint presets enable craft-ts/no-external-state-transition. It flags calls such as todos.replace(nextTodos) or todos.update(transition) outside the state insertion. Named state commands remain available to callers; simple values such as an input's setValue(value) are allowed. Prefer not to expose generic whole-state mutators from domain states.
The rule checks generic mutator method names on values created by Craft's state(...) primitive. It does not infer whether an arbitrarily named method such as replaceTodos(nextTodos) is a generic replacement; the state interface should make its command semantics clear.
Driving it from events
Bind a method to a source$ with on$ when the trigger is an event rather than a call:
const increment = source$<void>('increment');
const reset = source$<void>('reset');
const myState = yield* state('myState', 0, ({ update, set }) => ({
onIncrement: on$(increment, () => update((v) => v + 1)),
onReset: on$(reset, () => set(0)),
}));
increment.emit(); // after yield* / craftUse, myState is 1
reset.emit(); // after yield* / craftUse, myState is 0Like every craft primitive, a source is named, and the name must match the variable it is assigned to — the craft-ts/craft-source-name-match ESLint rule enforces it and autofixes it.
Note that onIncrement and onReset are not exposed on myState. Methods bound to a source work internally only.
Yielding dependencies
An insertion can be a function*, so it can pull in services:
yield* state('counter', 0, function* ({ state }) {
const log = yield* Console.log;
return {
logValue: function* () {
yield* log(`State value: ${yield* state()}`);
},
};
});Prefer yielding a craft service over reaching into a runtime container — yielding is what makes the dependency visible to the route DI check and to test registers.
Pitfalls
Don't duplicate derived state. If a value is a function of another, it is a craftComputed inside an insertion that yield*s its readers, or a state whose second argument is that source — not a second state kept in sync by an effect. assertCraftEffectNoImperativeSync fails the architecture suite when an effect writes another primitive.
Keep slices granular. One state per coherent concern. A single object holding five unrelated things makes every consumer depend on all five.
Advanced — scoping providers to one state
Use the object form with $self when a state needs its own provider scope:
const counter = yield* state(
'counter',
{
$self: function* () {
return yield* CounterPreferences.initialValue();
},
providers: [provideCounterPreferences(), provideCounterAnalytics()],
},
({ update }) => ({
increment: function* () {
yield* CounterAnalytics.track('increment');
return yield* update((value) => value + 1);
},
}),
);Advanced — injectable writes
Insertion methods also provide injectStateMethodRuntimeContext(), which recovers get, set, update, and patch from DI. Use it from wrappers, WebMCP tools, and other advanced patterns — everyday insertions already receive those methods as arguments. See Anatomy of a primitive.
See Also
- Anatomy of a primitive
- Insertions
- craftService — packaging state behind a reusable boundary