Skip to content

Reactieve update cycles vermijden ​

Inleiding ​

Een veel voorkomende waarschuwing bij het ontwikkelen van Lit componenten is:

Element [component-name] scheduled an update (generally because a property
was set) after an update completed, causing a new update to be scheduled.
This is inefficient and should be avoided unless the next update can only
be scheduled as a side effect of the previous update.

Deze waarschuwing geeft aan dat je component een inefficiΓ«nt update patroon gebruikt dat kan leiden tot onnodige re-renders en prestatieproblemen. In deze gids leer je waarom dit gebeurt en hoe je het kunt voorkomen.

Wat is een update cycle? ​

Lit componenten hebben een reactive update lifecycle die automatisch triggert wanneer properties veranderen:

Property Change β†’ willUpdate() β†’ update() β†’ render() β†’ updated()

Het probleem ontstaat wanneer je tijdens deze cycle een property update triggert die een nieuwe cycle start:

Property Change β†’ willUpdate() β†’ **set andere property** β†’ NIEUWE CYCLE START
                                   ↑
                            Dit triggert warning!

Oorzaken van dubbele updates ​

1. Property updates in lifecycle methoden ​

❌ Probleem: Property update in willUpdate() ​

typescript
@customElement('user-list')
class UserList extends LitElement {
  @property({ type: Array }) users: User[] = [];
  @property({ type: Number }) count = 0; // ⚠️ Public property

  protected override willUpdate(changedProperties: PropertyValues): void {
    super.willUpdate(changedProperties);

    if (changedProperties.has('users')) {
      this.count = this.users.length; // ❌ Triggert nieuwe update!
    }
  }
}

Waarom is dit een probleem?

  1. users property verandert β†’ update cycle start
  2. willUpdate() wordt aangeroepen
  3. this.count wordt geset β†’ nieuwe update cycle wordt gescheduled
  4. Lit waarschuwt dat dit inefficiΓ«nt is

❌ Probleem: Property update in updated() ​

typescript
@customElement('data-table')
class DataTable extends LitElement {
  @property({ type: Boolean }) open = false;
  @property({ type: Boolean }) hasData = false; // ⚠️ Public property

  protected override updated(changedProperties: PropertyValues): void {
    super.updated(changedProperties);

    if (changedProperties.has('open') && this.open) {
      this.hasData = this._checkForData(); // ❌ Triggert nieuwe update!
    }
  }
}

❌ Probleem: Property update in event handlers tijdens lifecycle ​

typescript
@customElement('search-box')
class SearchBox extends LitElement {
  @property({ type: String }) query = '';
  @property({ type: Number }) resultCount = 0; // ⚠️ Public property

  render() {
    return html` <input .value=${this.query} @input=${this.handleInput} /> `;
  }

  private handleInput(e: Event): void {
    const input = e.target as HTMLInputElement;
    this.query = input.value; // Update 1
    this.resultCount = this._search(this.query).length; // ❌ Update 2!
  }
}

2. Slot change handlers ​

Slot change events kunnen tijdens de lifecycle optreden, vooral bij eerste render:

typescript
@customElement('card-container')
class CardContainer extends LitElement {
  @property({ type: Boolean }) hasContent = false; // ⚠️ Public property

  render() {
    return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
  }

  private handleSlotChange(event: Event): void {
    const slot = event.target as HTMLSlotElement;
    this.hasContent = slot.assignedElements().length > 0; // ❌ Kan warning trigger!
  }
}

Oplossingen ​

Oplossing 1: Gebruik @state() in plaats van @property() ​

De simpelste oplossing: als een property alleen intern wordt gebruikt en niet van buitenaf wordt gezet, gebruik dan @state():

typescript
@customElement('user-list')
class UserList extends LitElement {
  @property({ type: Array }) users: User[] = [];
  @state() private count = 0; // βœ… Internal state

  protected override willUpdate(changedProperties: PropertyValues): void {
    super.willUpdate(changedProperties);

    if (changedProperties.has('users')) {
      this.count = this.users.length; // βœ… Nu OK - geen warning!
    }
  }
}

Waarom werkt dit?

  • @state() is bedoeld voor interne, afgeleide state
  • Lit verwacht dat @state() properties kunnen veranderen tijdens lifecycle
  • Geen warning, component functioneert correct

Wanneer gebruiken:

DecoratorGebruik wanneerVan buitenaf setbaar?
@property()Public API van componentβœ… Ja
@state()Interne, afgeleide state❌ Nee

Oplossing 2: Gebruik getters (computed properties) ​

Voor afgeleide waardes, gebruik getters in plaats van opgeslagen state:

typescript
@customElement('user-list')
class UserList extends LitElement {
  @property({ type: Array }) users: User[] = [];

  // βœ… Getter: geen stored state, berekend on-demand
  private get count(): number {
    return this.users.length;
  }

  render() {
    return html` <div>Aantal gebruikers: ${this.count}</div> `;
  }
}

Voordelen:

  • βœ… Geen dubbele update cycles
  • βœ… Altijd gesynchroniseerd met source of truth
  • βœ… Geen extra memory voor opslag
  • βœ… Eenvoudig en idiomatisch

Nadelen:

  • ❌ Herberekend bij elke access (niet gecached)
  • ❌ Niet geschikt voor expensive computations

Best practice voor getters:

typescript
@customElement('data-grid')
class DataGrid extends LitElement {
  @property({ type: Array }) items: DataItem[] = [];
  @property({ type: String }) filter = '';

  // βœ… Simple getter - snel genoeg
  private get itemCount(): number {
    return this.items.length;
  }

  // βœ… Computed filter - ook OK
  private get filteredItems(): DataItem[] {
    if (!this.filter) return this.items;
    return this.items.filter((item) => item.name.toLowerCase().includes(this.filter.toLowerCase()));
  }

  render() {
    return html`
      <div>Gevonden: ${this.filteredItems.length} van ${this.itemCount}</div>
      ${this.filteredItems.map((item) => html`<div>${item.name}</div>`)}
    `;
  }
}

Oplossing 3: Gecachte computed properties met @state() ​

Voor expensive computations, gebruik @state() met expliciete cache invalidation:

typescript
@customElement('data-processor')
class DataProcessor extends LitElement {
  @property({ type: Array }) data: number[] = [];
  @state() private processedData: ProcessedData | null = null;

  protected override willUpdate(changedProperties: PropertyValues): void {
    super.willUpdate(changedProperties);

    // βœ… Update cache alleen als source veranderd
    if (changedProperties.has('data')) {
      this.processedData = this._expensiveComputation(this.data);
    }
  }

  private _expensiveComputation(data: number[]): ProcessedData {
    // Zware berekening...
    return {
      /* ... */
    };
  }
}

Wanneer caching gebruiken:

  • βœ… Computation is expensive (>1ms)
  • βœ… Source data verandert niet vaak
  • βœ… Resultaat wordt meerdere keren gebruikt in template

Oplossing 4: Slot change optimalisatie ​

Voor slot changes, gebruik getters of defensive checking:

Optie A: Getter (aanbevolen) ​

typescript
@customElement('card-container')
class CardContainer extends LitElement {
  // βœ… Geen stored state, direct check
  private get hasContent(): boolean {
    const slot = this.shadowRoot?.querySelector('slot');
    return (slot?.assignedElements().length ?? 0) > 0;
  }

  render() {
    return html`
      <div class=${classMap({ 'has-content': this.hasContent })}>
        <slot></slot>
      </div>
    `;
  }
}

Optie B: State met requestUpdate ​

typescript
@customElement('card-container')
class CardContainer extends LitElement {
  @state() private hasContent = false;

  render() {
    return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
  }

  private handleSlotChange(event: Event): void {
    const slot = event.target as HTMLSlotElement;
    const newHasContent = slot.assignedElements().length > 0;

    // βœ… Defensieve check: alleen updaten als waarde echt veranderd
    if (this.hasContent !== newHasContent) {
      this.hasContent = newHasContent;
    }
  }
}

Optie C: Async update ​

typescript
@customElement('card-container')
class CardContainer extends LitElement {
  @state() private hasContent = false;

  private handleSlotChange(event: Event): void {
    const slot = event.target as HTMLSlotElement;

    // βœ… Defer tot volgende microtask
    queueMicrotask(() => {
      this.hasContent = slot.assignedElements().length > 0;
    });
  }
}

Oplossing 5: Multi-property updates batchen ​

Als je meerdere gerelateerde properties moet updaten:

typescript
@customElement('form-validator')
class FormValidator extends LitElement {
  @property({ type: Object }) formData = {};
  @state() private isValid = false;
  @state() private errorCount = 0;
  @state() private errors: ValidationError[] = [];

  protected override willUpdate(changedProperties: PropertyValues): void {
    super.willUpdate(changedProperties);

    if (changedProperties.has('formData')) {
      // βœ… Bereken alles tegelijk
      const validationResult = this._validate(this.formData);

      // βœ… Set alle properties in één statement
      this.isValid = validationResult.isValid;
      this.errorCount = validationResult.errors.length;
      this.errors = validationResult.errors;

      // Lit batcht deze updates automatisch!
    }
  }
}

Lifecycle guideline ​

Overzicht van wat je wel en niet moet doen in elke lifecycle methode:

Lifecycle Methodβœ… Toegestaan❌ VermijdBest Practice
constructor()Property initialisatieDOM accessMinimale setup
connectedCallback()Event listeners toevoegenProperties settenSetup external resources
disconnectedCallback()CleanupProperties settenRemove listeners
willUpdate()Read properties, cache berekeningen⚠️ Set @property()Gebruik @state() voor afgeleide state
render()Return templateSide effects, property updatesPure function
updated()DOM queries, focus management⚠️ Set propertiesGebruik defensive checking
firstUpdated()One-time DOM setup-βœ… Property updates OK hier

Gedetailleerde lifecycle regels ​

willUpdate() - Property preparation ​

βœ… Goed:

typescript
protected override willUpdate(changedProperties: PropertyValues): void {
  super.willUpdate(changedProperties);

  // βœ… Update @state() properties
  if (changedProperties.has('items')) {
    this.filteredItems = this._filter(this.items);
  }

  // βœ… Bereken waardes voor gebruik in render()
  if (changedProperties.has('data')) {
    this.processedData = this._process(this.data);
  }
}

❌ Vermijd:

typescript
protected override willUpdate(changedProperties: PropertyValues): void {
  // ❌ DOM manipulation
  this.shadowRoot.querySelector('.item')?.remove();

  // ❌ Set @property() (alleen @state() is OK)
  this.publicProp = 'value';

  // ❌ Async operations
  this.fetchData();
}

render() - Template creation ​

βœ… Goed:

typescript
render() {
  // βœ… Pure template generation
  return html`
    <div class=${this.className}>
      ${this.items.map(item => html`<span>${item}</span>`)}
    </div>
  `;
}

❌ Vermijd:

typescript
render() {
  // ❌ Side effects
  console.log('Rendering...');
  this.trackAnalytics();

  // ❌ Property updates
  this.count = this.items.length;

  // ❌ DOM manipulation
  this.querySelector('.button')?.remove();

  return html`<div>...</div>`;
}

updated() - Post-render operations ​

βœ… Goed:

typescript
protected override updated(changedProperties: PropertyValues): void {
  super.updated(changedProperties);

  // βœ… DOM queries
  const element = this.shadowRoot.querySelector('.target');

  // βœ… Focus management
  if (changedProperties.has('open') && this.open) {
    this.shadowRoot.querySelector('input')?.focus();
  }

  // βœ… Measure DOM
  const height = this.offsetHeight;
}

⚠️ Defensief met property updates:

typescript
protected override updated(changedProperties: PropertyValues): void {
  super.updated(changedProperties);

  // ⚠️ Alleen als echt noodzakelijk
  if (changedProperties.has('items')) {
    const newCount = this._countVisible();

    // Defensive check!
    if (this.visibleCount !== newCount) {
      this.visibleCount = newCount;
    }
  }
}

Praktijkvoorbeelden ​

Voorbeeld 1: Dialog component met slot tracking ​

❌ Probleem:

typescript
@customElement('dcr-dialog')
class DcrDialog extends LitElement {
  @property({ type: String }) type: DialogType = 'custom';
  @property({ type: Boolean }) hasActions = false; // ⚠️ Public property

  render() {
    return html` <slot name="actions" @slotchange=${this.handleSlotChange}></slot> `;
  }

  private handleSlotChange(event: Event): void {
    const slot = event.target as HTMLSlotElement;
    // ❌ Warning: property update tijdens mogelijk lifecycle
    this.hasActions = slot.assignedElements().length > 0;
  }
}

βœ… Oplossing 1: @state()

typescript
@customElement('dcr-dialog')
class DcrDialog extends LitElement {
  @property({ type: String }) type: DialogType = 'custom';
  @state() private hasActions = false; // βœ… Internal state

  private handleSlotChange(event: Event): void {
    const slot = event.target as HTMLSlotElement;
    this.hasActions = slot.assignedElements().length > 0; // βœ… OK
  }
}

βœ… Oplossing 2: Getter

typescript
@customElement('dcr-dialog')
class DcrDialog extends LitElement {
  @property({ type: String }) type: DialogType = 'custom';

  // βœ… Berekend on-demand
  private get hasActions(): boolean {
    if (this.type !== 'custom') return true;

    const slot = this.shadowRoot?.querySelector('slot[name="actions"]');
    return (slot?.assignedElements().length ?? 0) > 0;
  }

  render() {
    return html`
      <div class=${classMap({ 'has-actions': this.hasActions })}>
        <slot name="actions"></slot>
      </div>
    `;
  }
}

Voorbeeld 2: Data filtering ​

❌ Probleem:

typescript
@customElement('product-list')
class ProductList extends LitElement {
  @property({ type: Array }) products: Product[] = [];
  @property({ type: String }) filter = '';
  @property({ type: Array }) filteredProducts: Product[] = []; // ❌ Public

  protected override willUpdate(changedProperties: PropertyValues): void {
    if (changedProperties.has('products') || changedProperties.has('filter')) {
      // ❌ Warning!
      this.filteredProducts = this.products.filter((p) => p.name.includes(this.filter));
    }
  }
}

βœ… Oplossing: Getter

typescript
@customElement('product-list')
class ProductList extends LitElement {
  @property({ type: Array }) products: Product[] = [];
  @property({ type: String }) filter = '';

  // βœ… Computed property
  private get filteredProducts(): Product[] {
    if (!this.filter) return this.products;
    return this.products.filter((p) => p.name.toLowerCase().includes(this.filter.toLowerCase()));
  }

  render() {
    return html` ${this.filteredProducts.map((p) => html`<div>${p.name}</div>`)} `;
  }
}

Voorbeeld 3: Form validation ​

❌ Probleem:

typescript
@customElement('login-form')
class LoginForm extends LitElement {
  @property() email = '';
  @property() password = '';
  @property({ type: Boolean }) isValid = false; // ❌ Public

  protected override willUpdate(changedProperties: PropertyValues): void {
    if (changedProperties.has('email') || changedProperties.has('password')) {
      // ❌ Warning!
      this.isValid = this.email.includes('@') && this.password.length >= 8;
    }
  }
}

βœ… Oplossing 1: @state() met willUpdate

typescript
@customElement('login-form')
class LoginForm extends LitElement {
  @property() email = '';
  @property() password = '';
  @state() private isValid = false; // βœ… Internal

  protected override willUpdate(changedProperties: PropertyValues): void {
    if (changedProperties.has('email') || changedProperties.has('password')) {
      this.isValid = this._validate(); // βœ… OK
    }
  }

  private _validate(): boolean {
    return this.email.includes('@') && this.password.length >= 8;
  }
}

βœ… Oplossing 2: Getter

typescript
@customElement('login-form')
class LoginForm extends LitElement {
  @property() email = '';
  @property() password = '';

  // βœ… Altijd up-to-date
  private get isValid(): boolean {
    return this.email.includes('@') && this.password.length >= 8;
  }

  render() {
    return html` <button .disabled=${!this.isValid}>Login</button> `;
  }
}

Debugging tips ​

1. Identificeer welke property de warning veroorzaakt ​

Voeg tijdelijke logging toe:

typescript
protected override willUpdate(changedProperties: PropertyValues): void {
  super.willUpdate(changedProperties);

  // Debug: log property changes
  changedProperties.forEach((oldValue, propName) => {
    console.log(`Property "${propName}" changed:`, oldValue, 'β†’', this[propName]);
  });

  // Je code...
}

2. Gebruik browser DevTools ​

Enable verbose Lit warnings in development:

typescript
// In je main.ts of app entry
if (import.meta.env.DEV) {
  // @ts-ignore
  window.litDisableWarnings = false;
}

3. Check stack trace ​

De warning in console bevat een stack trace die toont:

  • Welke property werd gezet
  • Vanuit welke methode
  • Tijdens welke lifecycle fase

Geavanceerde Edge Cases ​

1. Mixins en requestUpdate ​

Als je mixins gebruikt die interne state beheren (zoals ARIA delegation), let op met requestUpdate() calls.

❌ Probleem: Een mixin roept requestUpdate() aan wanneer een attribuut verandert, maar dit gebeurt soms tijdens de initialisatie van de component.

typescript
override attributeChangedCallback(name, old, new) {
  super.attributeChangedCallback(name, old, new);
  // ... logica ...
  this.requestUpdate(); // ❌ Kan warning triggeren tijdens init!
}

βœ… Oplossing: Laat Lit's automatische change detection zijn werk doen, of check of een update nodig is:

typescript
// Check of update al pending is
if (this.isConnected && !this.isUpdatePending && this.hasUpdated) {
  this.requestUpdate();
}

2. Reactive Controllers ​

Controllers die hostUpdate of hostUpdated implementeren, mogen daarin geen nieuwe update triggeren via host.requestUpdate().

❌ Probleem:

typescript
class PositionController implements ReactiveController {
  hostUpdated() {
    this.calculatePosition();
    this.host.requestUpdate(); // ❌ Trigger warning: update na update!
  }
}

βœ… Oplossing: Gebruik isUpdatePending check of defer de update als het echt nodig is (bv. na metingen):

typescript
if (!(this.host as any).isUpdatePending) {
  this.host.requestUpdate();
}

3. Child Components creΓ«ren in firstUpdated ​

Als je in firstUpdated nieuwe child components creΓ«ert die zelf updates triggeren, kan dit een warning geven.

❌ Probleem:

typescript
override firstUpdated() {
  // CreΓ«ert component die direct update triggert
  this.appendChild(document.createElement('my-child'));
}

βœ… Oplossing: Defer met queueMicrotask

typescript
override firstUpdated() {
  // Defer naar na de huidige update cycle
  queueMicrotask(() => {
    this.appendChild(document.createElement('my-child'));
  });
}

4. Reflected Properties in firstUpdated ​

Als je een property met reflect: true set in firstUpdated, triggert dit attributeChangedCallback en een nieuwe update.

βœ… Oplossing: Ook hier helpt queueMicrotask:

typescript
override firstUpdated() {
  queueMicrotask(() => {
    this.reflectedProp = 'value';
  });
}

Samenvatting ​

Beslissingsboom ​

Property moet geupdatet worden tijdens lifecycle?
β”‚
β”œβ”€ Is het een PUBLIC API property (@property)?
β”‚  β”‚
β”‚  β”œβ”€ Ja β†’ ❌ NIET DOEN
β”‚  β”‚        Gebruik @state() of getter
β”‚  β”‚
β”‚  └─ Nee β†’ Ga verder
β”‚
β”œβ”€ Is het een afgeleide waarde (computed)?
β”‚  β”‚
β”‚  β”œβ”€ Ja, en berekening is goedkoop (<1ms)
β”‚  β”‚  └─ βœ… Gebruik getter
β”‚  β”‚
β”‚  └─ Ja, maar berekening is duur (>1ms)
β”‚     └─ βœ… Gebruik @state() met caching in willUpdate()
β”‚
└─ Is het interne state die kan veranderen?
   └─ βœ… Gebruik @state()

Key takeaways ​

  1. @property() = Public API, niet updaten tijdens lifecycle
  2. @state() = Internal state, mag wel updaten tijdens lifecycle
  3. Getters = Best voor afgeleide waardes
  4. willUpdate() = OK voor @state() updates
  5. render() = Alleen template, geen side effects
  6. updated() = Defensief property updates met checks

Volg deze richtlijnen en je component blijft efficiënt en warning-vrij! 🎯