There's a pattern I see a lot in Angular codebases that adopted Signals early: a developer discovers rxResource, loves that it handles loading and error state automatically, and then immediately reaches for tap() to write a signal inside the stream.
private readonly resource = rxResource({
params: () => this.paramsSignal(),
stream: ({ params }) =>
this.api.fetch(params).pipe(
tap(data => this.sideSignal.set(data.meta)) // 💥
)
});
This looks harmless. It runs in development without complaint in zone-based Angular. Then you enable zoneless — or Angular tightens its reactive graph enforcement — and you get NG0600: Writing to signals is not allowed in a reactive context.
The rxResource stream runs inside Angular's reactive scheduler. Signal writes there aren't just discouraged — they're illegal by design. The scheduler assumes computed signals and reactive contexts are read-only during evaluation. A write mid-computation breaks the glitch-free guarantee Angular's signal graph is built on.
The fix I landed on: make the stream return everything it needs to return, as a single typed value.
interface ResourceValue {
readonly sections: Section[];
readonly meta: Meta;
}
private readonly resource = rxResource<ResourceValue, Params>({
stream: ({ params }) =>
this.api.fetch(params).pipe(
map(data => ({
sections: transform(data),
meta: data.meta
}))
)
});
No tap. No side signal. Everything the rest of the store needs lives in resource.value() and can be read via computed.
The lesson isn't "don't use tap". The lesson is that rxResource has a contract: it is a read primitive. Its stream is for fetching and transforming. If you're writing signals inside it, you're treating it as a command bus — and that's a different tool.
Originally published on ysndmr.com.
Top comments (0)