Change detection and OnPush vs re-rendering and memo
Angular checks views in place when notified; React re-runs components below an update. Both skip unchanged inputs, but only Angular's events reach parents.
Predict first
Before you read: predict
ExamHall's Angular 22 app is zoneless. Its question list:
The page shows question-list with its 5 rows. A candidate clicks Flag on question 3.
Counting question-list and the five question-rows, how many component views does Angular check after the click?
@Component({ selector: 'question-row', changeDetection: ChangeDetectionStrategy.OnPush, template: '<p>{{ q.text }}</p> <button (click)="flagged = true">Flag</button> {{ flagged ? "flagged" : "" }}',})class QuestionRow { @Input() q!: Question; flagged = false; } @Component({ selector: 'question-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [QuestionRow], template: '@for (q of questions; track q.id) { <question-row [q]="q" /> }',})class QuestionList { questions = loadQuestions(5); }The 30-second version
Angular keeps every component and checks views: it re-reads template bindings and writes only what changed. Zoneless Angular checks after a notification: a template event, a signal a template reads, markForCheck(). An event checks its component, its ancestors and their Eager children; OnPush skips unchanged inputs. React instead re-runs component functions from the update downwards, unless memo skips one.
Angular checks views in place when notified; React re-runs components below an update. Both skip unchanged inputs, but only Angular's events reach parents.
How it works
Step 1 of 12
In the kitchen
- he compares each face with the report and writes down only what differs; nothing is dug again → a check re-reads bindings and writes only changes (step 1)
Analogy: A colliery deputy's inspection round
Before each shift a deputy walks the pit, district by district and face by face, with the last report.
| In the pit | In the code |
|---|---|
| he compares each face with the report and writes down only what differs; nothing is dug again | a check re-reads bindings and writes only changes (step 1) |
| the old pit sent him round after every shift change or delivery underground; now he goes only when a district telephone rings or a gas monitor trips | Zone.js against zoneless notifications (steps 2–3) |
| a sealed district is skipped unless new plans reach its entrance or someone inside rings its telephone | OnPush (step 5) |
| a call from one face takes him through every district on the way from the shaft | an event marks the ancestors (step 6) |
| a gas monitor sends him straight to that one face | a signal marks one view (step 7) |
| faces are logged by name, not by their order along the roadway, so a renumbered roadway keeps each face's history | track q.id (step 11) |
Breaks down when: a deputy may smell gas nobody reported; zoneless Angular never sees a change no notification reported (step 4). Wrong inference: "the next round will pick it up on its own."
Contrasting case: a surveyor who redraws the whole district map, and every map beneath it, whenever anything in the district changes, unless a sheet is stamped "unchanged": React re-rendering with memo (steps 8–9).
In real terms: Angular schedules checks from notifications and walks the view tree, skipping OnPush views that are not dirty; React calls component functions again from the updated component down, skipping memo children whose props are Object.is-equal.
Examples, run for real
4.1 ExamHall: EXH-3570 and MR !3870
The exam page reads a remaining signal in the question list's template, so every second the list's view is checked. EXH-3570 had 40 question rows with ChangeDetectionStrategy.Eager; MR !3870 made them OnPush and tracked them by id (track q.id), as in the hook:
4.1 EXH-3570 (Eager rows): 40 rows · 3 ticks of remaining · row views checked per tick 40, 40, 40 · list view checked per tick 1, 1, 1
4.1 MR !3870 (OnPush rows, track q.id): 40 rows · 3 ticks of remaining · row views checked per tick 0, 0, 0 · list view checked per tick 1, 1, 1
4.1 MR !3870 (OnPush rows, track q.id) reorder (3 flagged questions moved first): row components created 0 · rows on the page 40
With Eager rows every tick checks all 40 rows; with OnPush rows, which got no new input, it checks none (steps 5, 7). A reorder that moves flagged questions first keeps every row view when tracked by id (step 11).
4.2 The same fix in React
The React port keeps the remaining time in QuestionList's state. Rows are plain components in the port, then memo rows keyed by q.id in the fix:
const QuestionRow = memo(function QuestionRow({ q }) {
return <p>{q.text}</p>;
});
4.2 React port (plain rows): 40 rows · 3 ticks · QuestionList renders per tick 1, 1, 1 · row renders per tick 40, 40, 40
4.2 React fix (memo rows, key={q.id}): 40 rows · 3 ticks · QuestionList renders per tick 1, 1, 1 · row renders per tick 0, 0, 0
Every tick re-renders QuestionList, and without memo all 40 rows with it (step 8); memo skips rows whose q is the same object (step 9).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A ticket site's seat page runs on Angular 22, zoneless. The box office pushes updates through an event target:
The page is showing 120 seats left · €45. The hub then delivers price with 52, and after it seats with 87. Nothing else happens.
Which components now show the delivered value?
const hub = new EventTarget(); // the box office's push channel @Component({ selector: 'seat-count', template: '{{ free }} seats left' })class SeatCount { free = 120; private cd = inject(ChangeDetectorRef); constructor() { hub.addEventListener('seats', (e) => { this.free = (e as CustomEvent).detail; this.cd.markForCheck(); }); }} @Component({ selector: 'price-tag', template: '€{{ price() }}' })class PriceTag { price = signal(45); constructor() { hub.addEventListener('price', (e) => { this.price.set((e as CustomEvent).detail); }); }} @Component({ selector: 'box-office', imports: [SeatCount, PriceTag], template: '<seat-count /> · <price-tag />' })class BoxOffice {}Question 2 of 8
A payments ledger's Angular 22 page, zoneless:
A push sets pendingCount from 2 to 3.
Counting ledger-page, balance-header and the four entry-rows, how many component views does Angular check?
const pendingCount = signal(2); // set by a push from the ledger service @Component({ selector: 'entry-row', template: '{{ e.memo }}: {{ e.cents }}' })class EntryRow { @Input() e!: Entry; } @Component({ selector: 'balance-header', template: '{{ pending() }} pending' })class BalanceHeader { pending = pendingCount; } @Component({ selector: 'ledger-page', changeDetection: ChangeDetectionStrategy.Eager, imports: [BalanceHeader, EntryRow], template: '<h1>{{ title }}</h1> <balance-header /> @for (e of entries; track e.id) { <entry-row [e]="e" /> }',})class LedgerPage { title = 'Ledger'; entries = loadEntries(4); }Question 3 of 8
A chat app's Angular 22 log:
The log shows messages 1 to 20. After a reconnect, the client sets messages to a refetched list, messages 2 to 21: the oldest one dropped and one new message at the end. Every message in the refetched list is a new object parsed from JSON, with the same id, author and text as before.
How many message-row components does Angular create for the refetched list?
@Component({ selector: 'message-row', template: '{{ m.author }}: {{ m.text }}' })class MessageRow { @Input() m!: Message; } @Component({ selector: 'chat-log', imports: [MessageRow], template: '@for (m of messages(); track m) { <message-row [m]="m" /> }',})class ChatLog { messages = signal(fetchLatest(20)); }Question 4 of 8
An interviewer says: "Tell me about a change-detection performance problem you fixed in Angular, and how the fix would look in React." Use the ExamHall question list (EXH-3570, MR !3870).
Write four sentences.
Question 5 of 8
A teammate porting a ticket site from Angular says: "React has change detection too, memo is exactly OnPush, and key is the same as trackBy." Correct or confirm each claim.
Three sentences, one per claim.
Question 6 of 8
A ticket site's basket lets a buyer insure each ticket. The Angular 22 version, zoneless:
The React port:
function TicketRow({ t }) {
const [insured, setInsured] = useState(false);
return <p>{t.seat} <button onClick={() => setInsured(!insured)}>Insure</button> {insured ? 'insured' : ''}</p>;
}
function TicketBasket() {
const [tickets, setTickets] = useState(() => loadTickets(3));
return <div>{tickets.map((t, i) => <TicketRow key={i} t={t} />)}<button onClick={() => setTickets(tickets.slice(1))}>Remove first</button></div>;
}
Each basket holds seats A1, B2 and C3. In both, the buyer clicks Insure on seat B2, then Remove first, which drops A1.
In which basket is a seat the buyer never insured now shown as insured?
@Component({ selector: 'ticket-row', template: '{{ t.seat }} <button (click)="insured = !insured">Insure</button> {{ insured ? "insured" : "" }}',})class TicketRow { @Input() t!: Ticket; insured = false; } @Component({ selector: 'ticket-basket', imports: [TicketRow], template: '@for (t of tickets; track $index) { <ticket-row [t]="t" /> } <button (click)="tickets = tickets.slice(1)">Remove first</button>',})class TicketBasket { tickets = loadTickets(3); }Question 7 of 8
A video site's Angular 22 queue, zoneless; only queue-time sets changeDetection:
The page shows 3 queued · 60 min. The viewer clicks Add once.
Which number on the page changes?
@Component({ selector: 'queue-list', template: '{{ items.length }} queued' })class QueueList { @Input() items!: Video[]; } @Component({ selector: 'queue-time', changeDetection: ChangeDetectionStrategy.OnPush, template: '{{ items.length * 20 }} min' })class QueueTime { @Input() items!: Video[]; } @Component({ selector: 'player-page', imports: [QueueList, QueueTime], template: '<queue-list [items]="queue" /> · <queue-time [items]="queue" /> <button (click)="add()">Add</button>',})class PlayerPage { queue = loadQueue(3); add() { this.queue.push(nextVideo()); }}Question 8 of 8
A payments ledger shows 3 entries that expand on click. The Angular 22 version, zoneless:
The React port:
const EntryRow = memo(function EntryRow({ e }) {
const [open, setOpen] = useState(false);
return <p>{e.memo} <button onClick={() => setOpen(!open)}>Details</button></p>;
});
function LedgerTable({ entries }) {
return <div>{entries.map((e) => <EntryRow key={e.id} e={e} />)}</div>;
}
In both, the user clicks Details on entry 2.
Which parents run again after the click (Angular's ledger-table template, React's LedgerTable function)?
@Component({ selector: 'entry-row', template: '{{ e.memo }} <button (click)="open = !open">Details</button>' })class EntryRow { @Input() e!: Entry; open = false; } @Component({ selector: 'ledger-table', imports: [EntryRow], template: '@for (e of entries; track e.id) { <entry-row [e]="e" /> }',})class LedgerTable { entries = loadEntries(3); }Go deeper
Pro tip
- Before porting an OnPush list, count renders in the React port with a counter in the row body. Put
renders.current++in the row and let the timer tick: it rises by the row count per tick until the rows arememowith stable props. When: deciding whether a port needsmemo(steps 8–9). Status: RAN (React 19.3.0, jsdom 30.1.1).
Sources
They support the teaching; no key is cited. Valid as of 2026-10-12 (Angular 22.2, React 19.3).
- angular.dev, Angular without ZoneJS (Zoneless): https://angular.dev/guide/zoneless (
angular-zoneless.txt). Quoted: "ZoneJS uses DOM events and async tasks as indicators of when application state might have updated" (step 2) "Zoneless is the default in Angular v21+" (step 2) "Angular relies on notifications from core APIs in order to determine when to run change detection and on which views." (step 3) - angular.dev, Skipping component subtrees: https://angular.dev/best-practices/skipping-subtrees (
angular-skipping-subtrees.txt). Quoted: "OnPush is the default change detection strategy in Angular (since v22)." (step 5) "Angular compares the current and past value of the input with Object.is ." (steps 5, 9) "the framework will execute change detection in the entire component tree, including the component’s ancestors." (step 6) "you modify the object but preserve the reference, Angular will not invoke change detection." (steps 5, 9) - angular.dev, Control flow: https://angular.dev/guide/templates/control-flow (
angular-control-flow.txt), not named by the curriculum row; teaching support only. Quoted: "The track expression allows Angular to maintain a relationship between your data and the DOM nodes on the page." (step 11) "track the item by its reference identity using the triple-equals operator ( === )" (step 12) - react.dev, React Compiler: https://react.dev/learn/react-compiler (
react-compiler.txt). Quoted: "automatically optimizes your React application by handling memoization for you" (step 10)
Related lessons
Checked against the tools and docs as of 2026-10-12.