Skip to content

Polymer Properties and Observers — Reactive Property System and Observers

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Polymer Properties and Observers. We cover key concepts, practical examples, and best practices to help you master this topic.

LitElement's reactive property system tracks property changes and triggers re-renders. Observers let you react to specific property changes for running side effects.

What You'll Learn

  • Property change detection
  • The changedProperties map
  • Property observers (willUpdate, updated)
  • Computed properties with getters
  • Property side effects and Caching

Why It Matters

Understanding the reactive system lets you control when and how the component updates, optimizing performance and coordinating side effects.

Real-World Use

A dashboard widget that fetches data when a user ID or date range changes, caches results, and coordinates multiple dependent effects.

Property Architecture

flowchart TD
    A[Properties] --> B[Declarations]
    A --> C[Change Detection]
    A --> D[Observers]
    B --> E[Types]
    B --> F[Options]
    C --> G[RequestUpdate]
    C --> H[Update Cycle]
    D --> I[willUpdate]
    D --> J[updated]
    D --> K[Computed Getters]
    style A fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px

Basic Property Observer

import { LitElement, html } from 'lit';

class StatusMonitor extends LitElement {
  static properties = {
    status: { type: String },
    timeout: { type: Number }
  };

  constructor() {
    super();
    this.status = 'idle';
    this.timeout = 3000;
  }

  willUpdate(changedProperties) {
    if (changedProperties.has('status')) {
      console.log(`Status changing: ${changedProperties.get('status')} -> ${this.status}`);
    }
  }

  updated(changedProperties) {
    if (changedProperties.has('status') && this.status === 'error') {
      this._showNotification('Error occurred');
    }
    if (changedProperties.has('timeout')) {
      this._restartTimer();
    }
  }

  _showNotification(msg) { /* notify user */ }
  _restartTimer() { /* restart polling */ }

  render() {
    return html`<p class="status-${this.status}">Status: ${this.status}</p>`;
  }
}
customElements.define('status-monitor', StatusMonitor);

Expected output: willUpdate runs before render, updated runs after. changedProperties maps old values to current.

Multiple Property Dependencies

import { LitElement, html } from 'lit';

class DateRangePicker extends LitElement {
  static properties = {
    startDate: { type: String },
    endDate: { type: String },
    format: { type: String }
  };

  constructor() {
    super();
    this.startDate = '2026-01-01';
    this.endDate = '2026-12-31';
    this.format = 'short';
  }

  get dateRange() {
    if (this.format === 'short') {
      return `${this.startDate.slice(5)} - ${this.endDate.slice(5)}`;
    }
    return `${this.startDate} to ${this.endDate}`;
  }

  get duration() {
    const start = new Date(this.startDate);
    const end = new Date(this.endDate);
    return Math.ceil((end - start) / (1000 * 60 * 60 * 24));
  }

  get isValid() {
    return this.startDate && this.endDate && new Date(this.endDate) >= new Date(this.startDate);
  }

  updated(changedProperties) {
    if (changedProperties.has('startDate') || changedProperties.has('endDate')) {
      this.dispatchEvent(new CustomEvent('range-changed', {
        detail: { start: this.startDate, end: this.endDate, duration: this.duration }
      }));
    }
  }

  render() {
    return html`
      <input type="date" .value=${this.startDate}
        @change=${e => this.startDate = e.target.value}>
      <input type="date" .value=${this.endDate}
        @change=${e => this.endDate = e.target.value}>
      <p>Range: ${this.dateRange} (${this.duration} days)</p>
      ${this.isValid ? '' : html`<p class="error">Invalid range</p>`}
    `;
  }
}
customElements.define('date-range-picker', DateRangePicker);

Expected output: The dateRange getter depends on startDate and endDate. When either changes, the computed value updates.

Property Side Effects

import { LitElement, html } from 'lit';

class SearchPanel extends LitElement {
  static properties = {
    query: { type: String },
    debounceMs: { type: Number },
    results: { type: Array },
    loading: { type: Boolean }
  };

  constructor() {
    super();
    this.query = '';
    this.debounceMs = 300;
    this.results = [];
    this.loading = false;
    this._debounceTimer = null;
  }

  willUpdate(changedProperties) {
    if (changedProperties.has('query')) {
      this._scheduleSearch();
    }
  }

  _scheduleSearch() {
    clearTimeout(this._debounceTimer);
    if (!this.query.trim()) {
      this.results = [];
      return;
    }
    this._debounceTimer = setTimeout(() => this._performSearch(), this.debounceMs);
  }

  async _performSearch() {
    this.loading = true;
    const results = await this._fetchResults(this.query);
    this.results = results;
    this.loading = false;
  }

  async _fetchResults(query) {
    return [{ id: 1, name: `Result for ${query}` }];
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    clearTimeout(this._debounceTimer);
  }

  render() {
    return html`
      <input type="search" .value=${this.query}
        @input=${e => this.query = e.target.value} placeholder="Search...">
      ${this.loading ? html`<p>Searching...</p>` : ''}
      <ul>${this.results.map(r => html`<li>${r.name}</li>`)}</ul>
    `;
  }
}
customElements.define('search-panel', SearchPanel);

Expected output: Debounced side effect triggers when query changes. Loading state updates during async search.

Property Effects with Caching

import { LitElement, html } from 'lit';

class DataCache extends LitElement {
  static properties = {
    userId: { type: Number },
    refreshInterval: { type: Number }
  };

  constructor() {
    super();
    this.userId = 1;
    this.refreshInterval = 0;
    this._cache = new Map();
    this._cacheExpiry = 60000;
  }

  get userData() {
    const cached = this._cache.get(this.userId);
    if (cached && Date.now() - cached.timestamp < this._cacheExpiry) {
      return cached.data;
    }
    return null;
  }

  get isLoading() {
    const cached = this._cache.get(this.userId);
    return !cached || Date.now() - cached.timestamp >= this._cacheExpiry;
  }

  willUpdate(changedProperties) {
    if (changedProperties.has('userId') || this.isLoading) {
      this._fetchData();
    }
  }

  async _fetchData() {
    const data = await fetch(`/api/users/${this.userId}`).then(r => r.json());
    this._cache.set(this.userId, { data, timestamp: Date.now() });
    this.requestUpdate();
  }

  render() {
    const data = this.userData;
    if (!data) return html`<p>Loading...</p>`;
    return html`<div>${data.name} - ${data.email}</div>`;
  }
}
customElements.define('data-cache', DataCache);

Expected output: userId changes trigger fetch. Cache prevents refetch within expiry window.

Complex Observer Patterns

import { LitElement, html } from 'lit';

class FormValidator extends LitElement {
  static properties = {
    fields: { type: Array },
    rules: { type: Object },
    valid: { type: Boolean }
  };

  constructor() {
    super();
    this.fields = [{ name: 'email', value: '' }, { name: 'password', value: '' }];
    this.rules = {
      email: v => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? '' : 'Invalid email',
      password: v => v.length >= 8 ? '' : 'Too short'
    };
    this.valid = false;
  }

  get errors() {
    return this.fields.reduce((acc, field) => {
      const rule = this.rules[field.name];
      if (rule) {
        const error = rule(field.value);
        if (error) acc[field.name] = error;
      }
      return acc;
    }, {});
  }

  updated(changedProperties) {
    if (changedProperties.has('fields')) {
      this.valid = Object.keys(this.errors).length === 0;
    }
  }

  _updateField(name, value) {
    this.fields = this.fields.map(f =>
      f.name === name ? { ...f, value } : f
    );
  }

  render() {
    return html`
      ${this.fields.map(f => html`
        <div>
          <label>${f.name}:
            <input .value=${f.value} @input=${e => this._updateField(f.name, e.target.value)}>
          </label>
          ${this.errors[f.name] ? html`<span class="error">${this.errors[f.name]}</span>` : ''}
        </div>
      `)}
      <button ?disabled=${!this.valid}>Submit</button>
    `;
  }
}
customElements.define('form-validator', FormValidator);

Expected output: Validation runs on every field change. valid property reflects overall form validity.

Common Mistakes

  1. Mutating arrays/objects without new reference - LitElement uses === comparison.

  2. Using observers for derived state - Use getters instead.

  3. Triggering side effects in render() - Use willUpdate or updated.

  4. Forgetting to clean up timers - Clear in disconnectedCallback.

  5. Calling requestUpdate without change - Causes unnecessary re-renders.

Practice Questions

  1. What is the difference between willUpdate and updated?
  2. How do you observe changes on multiple properties?
  3. How do cached computed properties work in LitElement?
  4. How do you prevent unnecessary re-renders?
  5. How do you trigger side effects after a property change?

Challenge: Build a real-time data dashboard with multiple data sources (API polling), response caching with expiry, sort/filter controls, and auto-refresh interval. Track loading/error/empty states per data source.

FAQ

Can I observe property changes outside the component?

Yes. Use updated lifecycle and dispatch custom events.

How do I handle deeply nested property changes?

Use immutable updates or the @property decorator's hasChanged function.

Does LitElement support computed properties natively?

Use JavaScript getters on the class. They recalculate when dependent properties trigger re-render.

Can I observe property changes in a parent component?

Parent can listen to child's updated lifecycle via custom events.

Mini Project

Build a multi-step wizard with: step tracking property, validation per step, computed progress bar, side effects for step transitions (API calls), navigation guards (prevent invalid next step), and completion summary.

What's Next

Properties drive reactivity. Learn how Polymer Events handle user interaction and component communication.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro