State
@state turns a class field into a signal.
export class Counter extends Component {
@state count = 0;
}
Reading it inside render() subscribes this component to it. Assigning to it schedules a
re-render. There is no setter and no dependency list.
What tracking actually means
The subscription is per field, established by the read. A component that never reads
this.expanded does not re-render when expanded changes, even though the field is on the same
instance.
That has a practical consequence worth knowing: what re-renders is decided by your render(),
not by where the write came from. Moving a read out of render() and into an event handler
removes the subscription.
Assignment is the update
this.count = this.count + 1; // ✓
this.items = [...this.items, next]; // ✓
Mutating in place is not. The signal compares values, and an array you pushed into is the same array.
this.items.push(next); // ✗ nothing re-renders
In development this is reported as RMD005. In production it is simply silent, which is why the
diagnostic exists.
The same applies to objects: replace, do not mutate.
this.user = { ...this.user, name }; // ✓
Do not write state during a render
render() must be a function of state, not a place that changes it. A write during a render
schedules another render from inside the render that caused it.
render() {
this.seen = true; // ✗ RMD001
return <p>…</p>;
}
Put it in @create if it is initialisation, or in an event handler if it
is a response to something.
Batching, and when the DOM catches up
Writes are batched through a microtask. Several assignments in one turn produce one render.
this.a = 1;
this.b = 2;
this.c = 3;
// one render
The DOM is therefore one microtask behind the assignment. In an app that is invisible and desirable. It shows up in exactly one place:
Testing a change
import { render, act } from "@ramonda/testing-library";
const { instance, getByText } = render<Counter>(<Counter />);
act(() => { instance.count = 5; });
expect(getByText("count is 5")).toBeTruthy();
act runs the callback and then commits everything it caused — every pending render, @mount
and effect — however deep the cascade goes. Without it the assertion reads the DOM as it was
before the write.
Note instance: because state is a field on an object, a test can set it directly rather than
having to find an event that would. Both are worth testing; this one is for setting up a state
that would take six clicks to reach.
After a component is gone
Writing to a destroyed component's state is dropped, not applied — a fetch that resolves after
the user navigated away cannot schedule a render into a detached tree. In development it is
reported as RMD008. The drop ships in production too.
Values that are not signals
Not every field needs to be one. A plain field is fine for anything a render never reads.
@persist marks a plain field as part of the hydration payload without making it reactive —
for set-once, render-relevant values on a prerendered page. See server rendering.