When a model streams a UI into an Angular app, the spec behind the screen changes many times per second. Two questions follow from that, and both are easy to answer by guessing:
- Angular has no
render(node)function that calls itself. So how does it draw a tree when nobody knows how deep it goes? - On every patch, does Angular redraw everything? And when one deep child changes, how does Angular even know?
I measured instead of guessing, and read Angular's source where the answer wasn't obvious.
The examples come from ngx-json-render, an Angular renderer for json-render specs. If the format is new to you, my first article explains it. But the mechanics below are plain Angular: NgComponentOutlet, @for, dependency injection and signals.
How Angular draws a tree it has never seen
A spec describes a tree as a flat map of elements:
{
"root": "card",
"elements": {
"card": { "type": "Card", "props": {}, "children": ["title", "list"] },
"title": { "type": "Text", "props": { "content": "Hello" } },
"list": { "type": "List", "props": {}, "children": ["a", "b"] },
"a": { "type": "Text", "props": { "content": "A" } },
"b": { "type": "Text", "props": { "content": "B" } }
}
}
There is no recursive function in the renderer. The recursion is made of three ordinary Angular tools that call each other through templates.
Tool 1: NgComponentOutlet picks a component class at runtime. One small component does only that: take a key, find the element, create the matching component.
@Component({
selector: 'jr-element',
imports: [NgComponentOutlet],
template: `
@if (component()) {
<ng-container *ngComponentOutlet="component(); injector: injector" />
}
`,
})
export class JrElement {
readonly elementKey = input.required<string>();
// "Card" → CardComponent, looked up in the registry
}
Tool 2: @for renders the children, one jr-element per child key:
@Component({
selector: 'jr-children',
imports: [JrElement],
template: `
@for (key of childKeys(); track key) {
<jr-element [elementKey]="key" />
}
`,
})
export class JrChildren {}
Tool 3: your own component puts <jr-children /> wherever its children should go:
@Component({
selector: 'app-card',
imports: [JrChildren],
template: `<section class="card"><jr-children /></section>`,
})
export class CardComponent {}
Put them together and you get the loop:
jr-element "card" → app-card
jr-children → jr-element "title" → app-text
→ jr-element "list" → app-list
jr-children → jr-element "a" → app-text
→ jr-element "b" → app-text
Nobody calls anything recursively. Components create components, and the tree grows as deep as the data says.
How does a child know where it is?
The parent passes only one input: the key. Everything else travels through dependency injection. Each jr-element creates a small injector for the component it renders:
readonly injector = Injector.create({
providers: [
{ provide: RENDER_CONTEXT, useValue: this.renderContext }, // props, events
{ provide: RENDER_PATH, useValue: this.path }, // my ancestry
],
parent: inject(Injector),
});
Your component calls injectRenderContext() and gets its props as signals. The next jr-element down injects the parent's RENDER_PATH and adds itself to it.
That's the part I find elegant: Angular's injector tree plays the role of the call stack. In a recursive function, each call sees its caller's arguments. Here, each level sees its parent's providers.
And since every component was compiled ahead of time, the runtime never compiles anything. It only decides which already-compiled component to create next.
The cost of recursion that lives in Angular
Angular creates and checks views with plain recursive JavaScript functions. And each level of the JSON becomes several nested Angular views: jr-element, its @if block, your component, jr-children, its @for block.
So the depth of the JSON becomes the depth of the JavaScript call stack. That has a price: two elements that name each other as children are enough to run the stack out and kill the tab. Where that recursion has to stop is a story of its own.
Streaming: does Angular redraw everything?
With streaming, the model doesn't send a finished spec. It sends small patches, one per line:
{"op":"add","path":"/elements/card","value":{"type":"Card","props":{},"children":["title","list"]}}
{"op":"add","path":"/elements/title","value":{"type":"Text","props":{"content":"Hello"}}}
{"op":"replace","path":"/elements/a/props/content","value":"A2"}
Each patch produces a new spec object, which reaches the renderer through a signal. The natural fear: new object at the top means every component redraws, dozens of times per second.
I measured it instead of guessing. The spec above, with counters in every component: how many components were created, and how many component templates ran again.
| What the patch changed | Components created | Templates re-run |
|---|---|---|
| First render, 5 elements | 5 | 5 |
A prop of a deep leaf (a) |
0 |
1 (only a) |
A new child key in list, element not arrived yet |
0 | 1 (list) |
| That child's element arrives | 1 | 1 (the new child) |
| New spec object, nothing inside changed | 0 | 0 |
So no, it doesn't redraw everything. Four ideas make that work, and the first one isn't about Angular at all.
Idea 1: a patch replaces only what it touches
The patch doesn't mutate the old spec, and it doesn't deep-copy it either. It copies only the objects on the path to the change:
before: spec → elements → card, title, list, a, b
patch: replace /elements/a/props/content
after: spec' → elements' → card, title, list, a', b
(same objects) (new)
card, title, list and b are the same objects in memory as before. So "did this element change?" becomes a single ===.
Idea 2: each element watches only its own piece
Every jr-element reads its element through a computed:
readonly element = computed(() => this.root.spec()?.elements?.[this.elementKey()]);
When the spec changes, all these computeds run again, and each one is a single lookup. But a computed only reports a change when the new value is a different object. For card, title, list and b the lookup returns the same object, so the change stops right there. Nothing that depends on them runs.
Idea 3: Angular refreshes views, not the whole tree
This is the answer to "how does Angular know a deep child changed?"
It doesn't search for it. In modern Angular every view has its own reactive consumer, which remembers exactly which signals its template read. I checked this in Angular 21's source. When one of those signals changes:
- that view is marked dirty;
- its parents only get a flag saying "a child below needs checking". Their own templates don't run (
markAncestorsForTraversal); - before refreshing a dirty view, Angular checks whether the signals it read really got new values, and skips the view if not (
consumerPollProducersForChange).
So on a deep change, Angular walks down the path to that one view, runs its template, and updates only the DOM bindings whose values actually differ. The parents stay untouched.
In a zoneless app, and zoneless is the default for new apps since Angular 21, there's nothing else that could trigger a check. The signal notification is what schedules the update.
Idea 4: new children don't rebuild the list
@for (key of childKeys(); track key) compares children by key. When list gets a third key, Angular keeps the two existing views and creates one new one.
Streaming adds a twist: a parent often lists a child before the child's element has arrived. That's fine. The jr-element for the key is created and renders nothing. When the element arrives, only that one component is created. That's rows 3 and 4 of the table.
What isn't free
To be fair about the limits of this:
- Every patch still does a small amount of work per element. Each element's computed runs once, and Angular walks the tree to find dirty views. It's cheap, but it grows with the size of the spec.
- Data is not structure. That one deserves its own section.
When the data changes, not the UI
Specs also carry data. A text can read { "$state": "/user/name" }, a list can repeat over /todos. A user types into a field, or the stream sends a new value, and the data changes while the structure stays the same.
The state store itself does this well. It copies only the path to the changed value, and writing the same value again does nothing at all.
The renderer layer is where precision gets lost today. Every element resolves its props against the current state, and resolving always builds a new props object, even when every value inside it is the same. A new object counts as a change, so Angular re-runs every component's template. The DOM still changes only where a value really differs, but the template work covers the whole tree.
I measured it on a small spec: a card, a static text, a text bound to /user/name, and a todo list. Then I tried one small change: treat two props objects as equal when all their values are the same (a shallow compare on the props computed).
| What changed in the data | Templates re-run today | With a shallow compare on props |
|---|---|---|
/user/name (read by one text) |
6 of 6, the static text included | 1 (only that text) |
| The same value written again | 0 | 0 |
A todo appended to /todos
|
7 of 7 (and 1 new component) | 1 (the new todo) |
Same idea as with patches: keep identity, and "nothing changed" becomes cheap. The fix just has to restore identity one step later, after props are resolved.
When I wrote this, it was an experiment, not a shipped change. It isn't free of trade-offs:
- Every element still re-resolves its props on every data change. The compare saves Angular's work, not that JavaScript. The next step would be remembering which state paths each element reads, and skipping the rest.
- Props that resolve to nested objects or arrays are new objects every time, so the shallow compare doesn't help them. It's no worse than today, though.
- An external store that mutates objects in place could be shown stale, because the reference stays the same while the content changes. That case is exactly why the renderer treats every store update as a change today.
Update (0.5.1): this is now shipped. A resolved element keeps its previous object when its props hold the same values, and the right-hand column of the table is what the release's tests measure. Three details differ from the experiment:
- With the built-in store, object and array values compare by reference. The store copies every path it writes, so a
{ "$state": "/todos" }prop keeps its identity until/todositself changes. Literal objects with expressions inside, and$computedresults, are still rebuilt on every resolution. - With an external
store, object and array values always count as changed, because such a store may write into its snapshot in place. Primitives still compare by value. - Elements with a
$bindStateor$bindItemprop keep the old behaviour. Their input can hold text that state hasn't seen yet, and the component's props effect has to run to put the DOM right.
The short version
How Angular recurses
- There's no recursive function:
NgComponentOutlet→ your component →@for→NgComponentOutlet. - The injector tree works as the call stack. Each level sees its parent's context.
- JSON depth becomes JavaScript stack depth.
What streaming re-renders
- Patches keep unchanged objects, so "unchanged" is a
===. - Each element watches only its own piece through a
computed. - Angular doesn't search for changes. It refreshes only views whose signals really changed, and walks through the parents without re-running them.
-
@for ... track keykeeps existing children and creates only new ones.
What data changes re-render
- Before 0.5.1, every template. The DOM still changed only where values differed.
- A shallow compare on resolved props brought it down to the one affected element in my test, and 0.5.1 ships it.
Fast streaming in Angular isn't about rendering fast. It's about keeping object identity, so that "nothing changed" is a === and Angular can skip almost everything.
Top comments (3)
The part of this that stuck with me is that "did anything change?" is never a comparison of contents — it is a comparison of references, and the whole design is just a machine for keeping references stable. Your four-column table does the work better than any benchmark: "new spec object, nothing inside changed -> 0 templates re-run" is the row people fear the most and it is the cheap one.
The props-resolution step is where I would push back gently, though. A shallow compare on the resolved props object buys you the win for flat values, and you already flagged that nested objects and arrays are rebuilt every time, so they fall straight back into "new reference = change". Did you look at memoising per (elementKey, statePaths-read) instead — resolving only the paths that element actually pulled, so a write to /user/name never touches the todo list at all? That would also give you the read-tracking you mentioned as the next step for free.
Thanks! "A machine for keeping references stable" is a better one-line summary than anything I wrote.
You're right about where the shallow compare runs out, with one update since the article went out: it shipped in 0.5.1, and a
{ "$state": "/todos" }prop doesn't fall back to "new reference = change". Resolution hands back the store's own array, and the built-in store copies only the path it writes, so an untouched array keeps its reference. (With an external store, object values still always count as changed, since such a store may write into its snapshot in place.) Where you're exactly right is literal objects with expressions inside, likestyle: { color: { $state: "/theme/color" } }, and$computedresults: those are rebuilt on every resolution.Per-path memoising is the next step, and in Angular I'd rather not build the (elementKey, paths) cache by hand, because the signal graph already is that cache. If resolution read each path through a shared per-path selector,
computed(() => getByPath(state(), path)), each element's props computed would record exactly which paths it read, including dynamic ones like$condbranches and template placeholders. A write to/user/namebumps one selector, the todo elements never re-resolve, and their nested objects keep their references for free.What's in the way:
@json-render/coreand takes the whole state object, not aget(path). So it's a hook upstream, a Proxy over the state that turns reads into signal reads, or a forked resolver.So yes, that's where this is heading.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.