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.
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
- Listen in
connectedCallback, render immediately: Register the event listener when the component mounts. Then callrender()immediately so components that mount after data loads do not wait for the next change. - Always null-check before rendering:
Store.menuisnulluntil the fetch completes. Rendering aloadingstate when data is absent is the correct fallback. - Mix
innerHTMLandcreateElementfreely: Both are valid.innerHTMLis faster for static structures;createElementgives more control over individual nodes. - 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. event.targetvsevent.currentTarget:currentTargetis always the element with the listener.targetis the deepest element actually clicked. Usetarget.tagNameto 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:
// Wrong: misses data that already loaded
connectedCallback() {
window.addEventListener('app:menuchange', () => {
this.render();
});
}The fix is to render immediately on connect AND on change:
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
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:
// 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:
// 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
- MDN: Event.target - The element that dispatched the event, which may be a descendant of the listener element.
- MDN: Event.currentTarget - Always the element the listener is attached to.
Video:
- Web Components Crash Course by Traversy Media. Covers building real components with data rendering, relevant to the ProductItem pattern here.
Keep reading