Lifecycle Hooks
WebJs ships the full lit-aligned component lifecycle. AI coding agents have substantial training data on lit, so adopting lit's hook names and semantics lets agents write idiomatic WebJs code without framework-specific translation.
The Update Cycle
Every render goes through this pipeline. Each hook receives a changedProperties Map where keys are property names and values are the previous value before the change. Signal reads inside render() are tracked separately by the built-in SignalWatcher; signal changes schedule the next update but don't appear in this Map.
shouldUpdate(changedProperties)returnsfalseto skip this update entirely.willUpdate(changedProperties)is the pre-render hook. Safe to set reactive properties; assignments fold into this cycle.- Controllers'
hostUpdate() update(changedProperties)is the render-and-commit step. The default implementation callsrender()and commits to the render root.- Controllers'
hostUpdated() firstUpdated(changedProperties)runs once, on the first render only.updated(changedProperties)runs after every render commit.updateCompletePromise resolves.
render()
The template the component should produce for the current state. Returns a TemplateResult via the html tag.
render() {
return html`
<p>${this.filtered.length} active items</p>
<ul>${this.filtered.map(i => html`<li>${i.name}</li>`)}</ul>
`;
}
shouldUpdate(changedProperties)
Decide whether to render at all. Default returns true. Use to skip expensive renders when only irrelevant properties changed.
shouldUpdate(cp) {
return cp.has('items') || cp.has('mode');
}
willUpdate(changedProperties)
Compute derived values from inputs before render() reads them. Property assignments inside willUpdate fold into the current cycle without triggering another update.
willUpdate(cp) {
if (cp.has('items')) {
this.totalCount = this.items.length;
}
}
update(changedProperties)
The render-and-commit step. The default implementation calls render() and commits to the render root. Override only when you need to wrap or short-circuit the commit. Most users override render() instead.
updated(changedProperties)
Post-render DOM work. Runs after every commit (both the first render and all subsequent ones). Inspect changedProperties to branch on what changed this cycle. This is the right place for ad-hoc DOM work that previously needed requestAnimationFrame shims.
updated(cp) {
if (cp.has('open') && this.open) {
this.querySelector('input')?.focus();
}
}
firstUpdated(changedProperties)
Runs once, after the first render. Use for one-time DOM-dependent setup: focus, measurements, third-party library init on a DOM node. The changedProperties Map on the first render contains every reactive property that has a value, with undefined as the old value.
firstUpdated() {
this.shadowRoot?.querySelector('input')?.focus();
this._chart = new Chart(this.shadowRoot.querySelector('canvas'));
}
connectedCallback fires before the first render, so shadow children don't exist there yet. firstUpdated is the post-render equivalent.
updateComplete (and getUpdateComplete)
A Promise that resolves after the next render commit. await el.updateComplete in tests or in code that needs to read the post-render DOM after triggering an update. Override getUpdateComplete() to chain additional async work.
el.count = 5; await el.updateComplete; // DOM now reflects count = 5
State mutation
Signals are the default state primitive. Mutating a signal that the render() reads schedules a microtask-batched re-render via the component's built-in SignalWatcher. Multiple signal.set calls in the same microtask coalesce into one render. Reactive properties (declared via the WebComponent({ ... }) factory) follow the same scheduler and surface their own entries in changedProperties.
this.count.set(this.count.get() + 1);
this.name = 'updated'; // reactive property assignment
// One render. changedProperties.has('name') is true; signal change drove the watcher.
For a fine-grained binding that updates a single template hole without re-running the host's render() (and without going through the lifecycle hooks above), see the watch(signal) directive in the Components doc. Lifecycle hooks fire only on a full re-render; watch()-driven updates bypass them.
requestUpdate(name, oldValue)
Manually schedule a re-render. Optionally record a property change so hooks see it in changedProperties. Used by controllers and code that mutates outside the reactive property system.
this.requestUpdate('items', oldItems);
renderError(error)
Runs when update()/render() throws. Return a fallback template to show instead of crashing the page.
renderError(error) {
return html`<p style="color:red">Error: ${error.message}</p>`;
}
Without this, one broken component would crash the entire page. The default implementation renders nothing and logs to console.
async render() and renderFallback()
A component's render() may be async, so it can fetch its own server data into the first paint. Writing await makes the function async; WebJs awaits a promise-returning render() automatically on both the server and the client. There is no flag.
async render() {
const user = await getUser(this.uid); // a 'use server' action: real fn at SSR, RPC stub on the client
return html`<h3>${user.name}</h3>`;
}
Three concerns stay separate. First, SSR always blocks, so the resolved data is in the first paint with no fallback (JS-off reads it). Second, the client re-fetch default is stale-while-revalidate: a prop change re-runs async render() and the current content stays until the new render resolves (no blank, no flash). Third, renderFallback() is the optional loading UI shown ONLY during a client re-fetch, never on the first paint.
renderFallback() {
return html`<div class="skeleton h-24"></div>`; // shown only while a re-fetch is in flight
}
A failed async render() (a thrown await getData()) is isolated to that component automatically: siblings render, the page does not blank, and renderError() optionally customizes the error UI. To STREAM slow data with a first-paint fallback, wrap the region in <webjs-suspense .fallback=${html`…`}> (it flushes the fallback on the first byte, then streams the data in, progressively on soft navigation too). See Components and Data fetching for the full decision guide and anti-patterns.
Native Web Component Callbacks
These are provided by HTMLElement itself and work as normal in WebJs components:
connectedCallback()fires when the element is added to DOM (callsuper.connectedCallback())disconnectedCallback()fires when the element is removed from DOMattributeChangedCallback(name, old, new)fires when an observed attribute changesstatic observedAttributes: declares which attributes to watch
SSR vs Browser: which hooks run where
The SSR pipeline runs each component to produce its first-paint HTML. It runs the pre-render value-deriving hooks plus render(); the post-render and connection hooks run only in the browser after the script loads.
| Hook | Server (SSR) | Browser |
|---|---|---|
constructor() | ✅ | ✅ |
| attribute application (via the factory's property converters) | ✅ | ✅ |
willUpdate() | ✅ | ✅ |
controllers' hostUpdate | ✅ | ✅ |
reflect: true property reflection | ✅ | ✅ |
render() | ✅ | ✅ |
shouldUpdate() | ❌ | ✅ |
connectedCallback() | ❌ | ✅ |
disconnectedCallback() | ❌ | ✅ |
firstUpdated() / updated() | ❌ | ✅ |
attributeChangedCallback() | ❌ | ✅ |
controllers' hostUpdated | ❌ | ✅ |
Practical rule: set SSR-meaningful defaults in the constructor (or as an instance signal's initial value), and derive SSR-visible state in willUpdate. Use connectedCallback only for browser-only data (localStorage, viewport, navigator.*, observers, timers). Read the value and write the signal to refine the initial render after hydration. A Task is the exception among controllers: its hostUpdate does not auto-run server-side, so it ships the INITIAL state and fetches only on hydration.
Attribute and event methods are SSR-safe. The pre-render hooks run on a server instance that has no real DOM, but WebJs backs it with a server element shim, so the attribute methods (getAttribute / hasAttribute / setAttribute / toggleAttribute) work, the event methods (addEventListener / removeEventListener / dispatchEvent) are inert no-ops, and attachInternals() returns an inert object. So reading an attribute in render(), wiring a delegated listener in the constructor, or reflecting a property during the SSR update cycle all run without an isServer guard. closest() is shimmed too, for tag-name selectors only, so a compound component marks its active state in the first paint. Genuinely browser-only members (classList, querySelector, attachShadow, geometry, layout reads) have no shim and still throw at SSR; keep those in connectedCallback or a later hook. See Server-Side Rendering for the full shim surface.
See Progressive Enhancement for the full pattern, including how to push server-known data through the page function instead of fetching in browser-only hooks.