Why does an Angular collapsible panel with expanded = model(false), bound one-way as [expanded]="allOpen()", stop reopening on 'expand all' after a user collapses it?
answer
- the child wrote its own copy
- nothing flowed back up
- binding only writes on change
- true to true is no change
- per-panel state in the parent
basics
~20 sThe collapse was a child-side write the one-way binding never reported, so the parent still holds true; setting true again changes nothing, so Angular writes nothing. Keep per-panel state in the parent and bind [( )].
solid answer
~40 sA model input keeps whatever the child writes. With a one-way `[expanded]="allOpen()"`, the user's collapse calls `expanded.set(false)` inside the panel and emits `expandedChange`, but nobody listens, so the parent's `allOpen` stays `true`. 'Expand all' then sets `allOpen` to `true` again: the signal is unchanged, and even on a refresh Angular only writes a property binding into the child when the bound value differs from the last one it pushed. So `true` is never re-sent and the panel stays closed. The fix is to let the parent track each panel's actual state: give each panel its own signal and bind `[(expanded)]="panel.open"`, so collapses flow back and 'expand all' sets every one to `true`. Alternatively make the panel controlled with `input()` plus `output()`.
go deeper
Remember that a one-way binding never hears about the child's own writes to a model input.
Explain why re-setting the same value does nothing: the signal ignores equal writes and a property binding only writes when its value changed.
Diagnose the split ownership, reject the shared-flag two-way fix, and move per-item state into the parent or make the child controlled.
Establish when components own their state and when parents do, so commands like expand all have one source of truth to act on.
## The setup A page shows several collapsible sections. Each is an Angular component with a model input: ```ts import {Component, model, signal} from '@angular/core'; @Component({ selector: 'app-panel', template: ` <button (click)="expanded.update(v => !v)">{{ expanded() ? 'Hide' : 'Show' }}</button> @if (expanded()) { <ng-content /> } `, }) export class Panel { expanded = model(false); } @Component({ selector: 'app-faq', imports: [Panel], template: ` <button (click)="allOpen.set(true)">Expand all</button> <app-panel [expanded]="allOpen()">Shipping</app-panel> <app-panel [expanded]="allOpen()">Returns</app-panel> `, }) export class Faq { allOpen = signal(false); } ``` 'Expand all' works the first time. Then a user collapses 'Returns' with its own button and clicks 'Expand all' again. 'Returns' stays closed. ## Why it happens Three facts combine: 1. **The child's write is local.** `expanded.update(...)` changes the panel's own signal to `false` and emits `expandedChange(false)`. The parent bound only the input side, so the event has no listener and `allOpen` is still `true`. 2. **The parent's write is a no-op.** 'Expand all' calls `allOpen.set(true)` while `allOpen` is already `true`. A signal ignores a write of an equal value, so nothing is notified. 3. **A property binding writes only on change.** Even if the parent's view is refreshed for another reason, Angular compares the expression's value with the last value it pushed for that binding. It pushed `true` earlier and `true` is still the value, so the binding is considered unchanged and the child's input is not written. The child's `false` therefore survives indefinitely. It is not a change-detection bug and has nothing to do with `OnPush` being the default in Angular 22: every part behaves as designed. The design error is **two owners for one piece of state** with no channel back from the child. ## Diagnosing it - Log `allOpen()` and each panel's `expanded()`: the parent says `true`, one panel says `false`. - Note that 'expand all' works only when `allOpen` actually changes, for example after a 'collapse all' sets it to `false` first. - Search templates for one-way `[x]` bindings onto fields declared with `model()`; each is a place where the child can drift away from the parent. ## Fixes, and their tradeoffs | Fix | How | Tradeoff | |---|---|---| | Per-panel state, two-way | parent holds one signal per panel; `[(expanded)]="p.open"`; expand all sets each to `true` | the parent always knows reality; slightly more state in the parent | | Controlled panel | panel uses `expanded = input(false)` plus `expandedChange = output<boolean>()` and never writes its own input | parent must handle every toggle; the panel cannot work unbound | | Listen and reconcile | keep `[expanded]` and add `(expandedChange)` to record each panel's value | reinvents the two-way binding by hand | Binding one shared flag two-way, `[(expanded)]="allOpen"` on every panel, is not a fix: collapsing one panel writes `false` into the shared signal and every panel follows. The shared 'expand all' concept and the per-panel open state are different pieces of state. ## The corrected parent ```ts import {Component, signal} from '@angular/core'; import {Panel} from './panel'; @Component({ selector: 'app-faq', imports: [Panel], template: ` <button (click)="expandAll()">Expand all</button> @for (p of panels; track p.title) { <app-panel [(expanded)]="p.open">{{ p.title }}</app-panel> } `, }) export class Faq { panels = [ {title: 'Shipping', open: signal(false)}, {title: 'Returns', open: signal(false)}, ]; expandAll() { this.panels.forEach(p => p.open.set(true)); } } ``` Now a user's collapse writes `false` back into that panel's signal through `expandedChange`. 'Expand all' sets it from `false` to `true`, which is a real change, and the binding pushes `true` into the panel. ## The general lesson - A `model()` is designed for **two-way** use. Binding it one-way is allowed, and fine when the parent only seeds an initial value, but then the child owns the state and the parent's later pushes are only heard when its value actually changes. - If the parent needs to command the child repeatedly, the parent must know the child's current value: bind two-way or make the child controlled. - Watch for the same trap with plain-property parents: `this.allOpen = true` when it is already `true` is equally invisible to the binding. ## Verifying the fix A component test catches the regression directly. Render the parent, click 'Expand all', collapse one panel through its own button, click 'Expand all' again, and assert that every panel's content is visible. Before the fix the last assertion fails for the panel the user collapsed; after it, the collapse has already written `false` into that panel's signal, so the second 'Expand all' is a genuine `false` to `true` change and the binding pushes it down. It is also worth asserting the reverse path, that collapsing a panel leaves its neighbours open, which is exactly what the shared two-way flag would break.
- Would switching the parent to a plain boolean field instead of a signal fix it?No. The binding compares the expression's value with the last value pushed, whatever its source. Assigning `true` to a field that is already `true` is not a change, so the child's input is still not written. The fix is ownership: the parent must receive the child's writes.
- When is a one-way binding onto a model input actually fine?When the parent only supplies a starting value and does not need to know or override later edits, for example seeding a panel as open once. From then on the child owns the value, and the parent's pushes land only when its bound value really changes.
saying these in an interview costs you the question
- OnPush being the default in Angular 22 is why the panel does not reopen
- Angular re-writes every input binding on each change detection pass
- Binding one shared signal two-way to every panel fixes it
- The child should call markForCheck to receive the parent's value
- A model input always mirrors the parent even with a one-way binding