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 { state } from '@craft-ng/core';
import { computed } from '@angular/core';
const counter = yield* state('counter', 0, ({ state, update, set }) => ({
increment: () => update((value) => value + 1),
decrement: () => update((value) => value - 1),
reset: () => set(0),
isEven: computed(() => state() % 2 === 0),
}));
counter(); // 0 — the ref is a signal
counter.increment();
counter.isEven(); // false
counter.reset();The insertion context gives you state (the current value as a signal), set and update. Everything you return is exposed on the ref.
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 signal
The initial value can be a signal or a computed, in which case the state follows it:
const origin = signal(5);
const doubled = yield* state(
'doubled',
computed(() => origin() * 2),
);
doubled(); // 10Composing several insertions
One insertion function gets crowded. Split it and compose with insertStatePipe:
import { insertStatePipe } from '@craft-ng/core';
const counter = yield* state(
'counter',
0,
insertStatePipe(
({ update, set }) => ({
increment: () => update((current) => current + 1),
reset: () => set(0),
}),
({ state }) => ({
isOdd: computed(() => state() % 2 === 1),
}),
),
);
counter.increment();
counter.isOdd(); // trueEach function receives the same context and contributes its own slice. See Insertions.
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(); // myState() === 1
reset.emit(); // myState() === 0Like every craft primitive, a source is named, and the name must match the variable it is assigned to — the craft-ng/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;
effect(() => log(`State value changed: ${state()}`));
return {};
});Prefer yielding a craft service over calling Angular's inject — 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 computed inside an insertion, not a second state kept in sync by an effect.
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');
update((value) => value + 1);
},
}),
);See Also
- Anatomy of a primitive
- Insertions
- craftService — packaging state behind a reusable boundary