Rendering With Reactive Data

A component that renders only on data-change events misses the first render. connectedCallback needs to trigger rendering too. Here is how the pieces connect.

May 1, 20264 min read3 / 6

The proxy fires app:menuchange whenever the menu data is set. But if a component only listens for that event, it misses the case where data already exists when the component mounts. Getting this right -- render on connect AND on change -- is where the pattern becomes complete.

The Essentials

  1. Listen in connectedCallback, render immediately: Register the event listener when the component mounts. Then call render() immediately so components that mount after data loads do not wait for the next change.
  2. Always null-check before rendering: Store.menu is null until the fetch completes. Rendering a loading state when data is absent is the correct fallback.
  3. Mix innerHTML and createElement freely: Both are valid. innerHTML is faster for static structures; createElement gives more control over individual nodes.
  4. Nested components via document.createElement: When rendering a list of products, create a <product-item> element for each one. The custom element renders itself when appended.
  5. event.target vs event.currentTarget: currentTarget is always the element with the listener. target is the deepest element actually clicked. Use target.tagName to distinguish a button click inside a link.

Rendering on Both Connect and Change

A component that only listens for app:menuchange will show nothing if the menu loaded before the component mounted:

JavaScript
// Wrong: misses data that already loaded connectedCallback() { window.addEventListener('app:menuchange', () => { this.render(); }); }

The fix is to render immediately on connect AND on change:

JavaScript
connectedCallback() { const template = document.getElementById('menu-page-template'); this.root.appendChild(template.content.cloneNode(true)); window.addEventListener('app:menuchange', () => this.render()); this.render(); // render now with whatever data exists }

The first render() call runs synchronously with whatever is in the Store. If menu is null, it shows a loading state. When the fetch completes and sets Store.menu, the proxy fires the event and render() runs again with real data.

The Render Method

JavaScript
render() { const menuEl = this.root.querySelector('#menu'); if (!app.store.menu) { menuEl.innerHTML = '<p>Loading...</p>'; return; } menuEl.innerHTML = ''; for (const category of app.store.menu) { const li = document.createElement('li'); li.innerHTML = `<h3>${category.name}</h3><ul class="category"></ul>`; for (const product of category.products) { const item = document.createElement('product-item'); item.dataset.product = JSON.stringify(product); li.querySelector('ul').appendChild(item); } menuEl.appendChild(li); } }

Two loops: one over categories, one over products within each category. Each product becomes a <product-item> element. Its data is serialized to JSON in dataset.product because HTML attributes only accept strings.

The ProductItem Component

Each product row is its own custom element. It reads its data from dataset.product:

JavaScript
// components/ProductItem.js class ProductItem extends HTMLElement { connectedCallback() { const template = document.getElementById('product-item-template'); this.appendChild(template.content.cloneNode(true)); const product = JSON.parse(this.dataset.product); this.querySelector('h4').textContent = product.name; this.querySelector('.price').textContent = `$${product.price}`; this.querySelector('img').src = product.image; this.querySelector('a').addEventListener('click', event => { event.preventDefault(); if (event.target.tagName === 'BUTTON') { // button inside the link was clicked - add to cart addToCart(product.id); } else { // link itself was clicked - navigate to detail app.router.go(`/product/${product.id}`); } }); } } customElements.define('product-item', ProductItem); export default ProductItem;

event.target vs event.currentTarget

The product card wraps both the product detail link and an "Add to Cart" button inside a single <a> element. Clicking the button fires the click event on the <a>, because the button is a child.

event.currentTarget is always the <a> -- the element with the listener attached.

event.target is the actual element clicked: could be the <h4>, the <img>, or the <button>.

Checking event.target.tagName === 'BUTTON' distinguishes between the two actions without needing two separate listeners.

Registering Components Without Forgetting

Every custom element must appear in the import chain before the browser will recognize it. A useful pattern is to do all customElements.define calls -- or at minimum all component imports -- in app.js:

JavaScript
// app.js import './components/MenuPage.js'; import './components/DetailsPage.js'; import './components/OrderPage.js'; import './components/ProductItem.js'; import './components/CartItem.js';

The import itself is the registration. Without it, the browser silently renders the element as an empty unknown tag with no error.

Further Reading and Watching

Video:

  • Web Components Crash Course by Traversy Media. Covers building real components with data rendering, relevant to the ProductItem pattern here.