skip to content

Why does <button [attr.disabled]="isSaving()"> in an Angular template stay disabled after isSaving() becomes false, and what is the fix?

level: seniorimportance: should knowfreq 38%

answer

  1. presence, not value
  2. false becomes a string
  3. only nullish removes
  4. bind the property instead
  5. ARIA states want the string

basics

~20 s

An attribute binding writes false as the string 'false', and disabled is a boolean attribute whose mere presence disables the button; Angular removes an attribute only for null or undefined. Bind the property with [disabled], or use isSaving() ? '' : null.

solid answer

~40 s

`[attr.disabled]` calls `setAttribute('disabled', String(value))`, so `false` becomes `disabled="false"`. HTML boolean attributes such as `disabled`, `hidden`, `required` and `readonly` mean *true* whenever they are present, whatever their text, so the button stays disabled. Angular removes an attribute only when the bound value is `null` or `undefined`. The fix is to bind the **DOM property** `[disabled]="isSaving()"`, which takes a real boolean; if you must use the attribute, map false to `null`: `[attr.disabled]="isSaving() ? '' : null"`. Interpolation does not help: `disabled="{{ isSaving() }}"` is a property binding of the string `"false"`, which is truthy. ARIA states are the opposite case: `aria-expanded` and `aria-pressed` need the literal strings `"true"`/`"false"`, so there stringifying a boolean is exactly right and mapping to `null` would drop the state.

code

ts · 23 lines
ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-save-panel',
  template: `
    <button type="button" [disabled]="isSaving()" (click)="save()">Save</button>
    <button type="button" [aria-expanded]="open()" (click)="open.set(!open())">
      Details
    </button>
    @if (open()) {
      <section>...</section>
    }
  `,
})
export class SavePanel {
  protected readonly isSaving = signal(false);
  protected readonly open = signal(false);

  protected save() {
    this.isSaving.set(true);
    // ... on completion: this.isSaving.set(false);
  }
}

go deeper

for a junior

Remember that [attr.x] writes strings and that disabled stays on whenever the attribute exists; use [disabled] for booleans.

for a middle

Explain the stringify-and-remove rules of attribute bindings, why interpolation also fails, and how ARIA state attributes differ.

for a senior

Catch this class of bug in review and tests: toggle back to false, assert enabled, and keep ARIA states as true/false strings.

for a principal

Adopt a template lint or review rule that bans attribute bindings to boolean HTML attributes across the codebase.

## The bug A save button is meant to be disabled while a request is in flight: `<button [attr.disabled]="isSaving()">Save</button>` It disables correctly when saving starts, but never re-enables. In the DOM inspector the button carries `disabled="false"`. ## Why it happens Two rules combine: 1. **Angular's attribute binding stringifies.** `[attr.name]` writes any non-null value with `setAttribute`, converting it to a string. `true` becomes `"true"`, `false` becomes `"false"`. Only `null` and `undefined` trigger `removeAttribute`. 2. **HTML boolean attributes are presence-based.** For `disabled`, `hidden`, `required`, `readonly`, `checked`, `multiple` and similar attributes, the element is in the "on" state whenever the attribute exists. `disabled="false"` is still disabled. So the binding turns the button off once and can never turn it back on. ## Fixes | Binding | Result when `isSaving()` is `false` | Verdict | |---|---|---| | `[attr.disabled]="isSaving()"` | `disabled="false"` - still disabled | bug | | `disabled="{{ isSaving() }}"` | property set to the string `"false"`, which is truthy - still disabled | bug | | `[disabled]="isSaving()"` | `disabled` property `false` - enabled | recommended | | `[attr.disabled]="isSaving() ? '' : null"` | attribute removed - enabled | works | The **property binding** is the idiomatic fix: the `disabled` property is a boolean, the browser keeps the attribute in sync, and the template reads naturally. Use the attribute form only when there is no property (a custom element that only observes attributes, for example). ## The mirror-image trap: ARIA states ARIA **state** attributes are *not* boolean attributes in the HTML sense. `aria-expanded`, `aria-pressed`, `aria-selected`, `aria-checked` and `aria-hidden` take the strings `"true"` and `"false"`, and the **absence** of the attribute often means something different again - for `aria-expanded`, absence means "this control does not expand anything". - `[aria-expanded]="open()"` (or `[attr.aria-expanded]`) with a boolean writes `"true"` / `"false"` - correct. - `[aria-expanded]="open() ? 'true' : null"` removes the attribute when closed, so assistive technology no longer announces a collapsed state - a regression. So the rule is not "always map false to null"; it is "know whether the attribute is presence-based or value-based". ## Boolean attributes and the properties to bind instead - `disabled` - bind `[disabled]` on buttons, inputs, selects and fieldsets. - `hidden` - bind `[hidden]`; remember a CSS `display` rule can still override it. - `required` and `readonly` - bind `[required]` and `[readOnly]` (Angular also maps `[readonly]` to `readOnly`). - `checked` and `selected` - bind `[checked]` and `[selected]`, which reflect the live state rather than the default. - `multiple` - bind `[multiple]` on `<select>` and file inputs. - `open` on `<details>` and `<dialog>` - bind `[open]`, though dialogs are better opened through their methods. ## A review checklist 1. Search templates for `[attr.` bindings to boolean HTML attributes (`disabled`, `hidden`, `required`, `readonly`, `checked`, `selected`, `open`); replace them with property bindings. 2. Search for interpolated boolean attributes such as `hidden="{{ ... }}"` - they are property bindings of strings. 3. For ARIA states, confirm that the bound value is a boolean or `"true"`/`"false"`, and that `null` is used only when the state should genuinely be absent. 4. Add a component test that toggles the signal back to `false` and asserts the element is enabled - the initial render alone never reveals this bug. ## Why property bindings are the default Property bindings carry typed values (booleans, numbers, objects) straight to the DOM or to component inputs, and Angular checks the property name at compile time. Attribute bindings exist for the cases where there is no property to target. Defaulting to properties avoids an entire class of stringification bugs.

  • Why does disabled="{{ isSaving() }}" not fix the problem?
    Interpolation in an attribute value compiles to a property binding of the interpolated string. The `disabled` property receives `"false"`, a non-empty string, which the browser treats as true. Bind `[disabled]="isSaving()"` so the property gets a real boolean.
  • When is [attr.disabled] with a null mapping the right choice?
    When the target only understands the attribute, such as a custom element that watches `disabled` through observed attributes, or when server-rendered markup must carry or omit the attribute itself. Write `[attr.disabled]="flag ? '' : null"` so the attribute exists only while the flag is true.

saying these in an interview costs you the question

  • [attr.disabled]="false" removes the disabled attribute
  • Interpolating disabled="{{ flag }}" fixes boolean attribute bindings
  • Every false ARIA value should be mapped to null
  • aria-expanded is a boolean attribute that works by presence
  • Angular removes attributes for any falsy value