Lit Interview Questions and Answers

Last updated:

Check out 35 of the most common Lit interview questions, then take an AI-powered practice interview

Web ComponentsJavaScriptTypeScriptShadow DOMCustom Elements
35+
Questions
14
Basic
14
Intermediate
7
Advanced
Q1

How is Lit different from React or Vue in what it actually produces?

BasicFundamentals

Answer

React and Vue produce components that only exist inside their own runtime. A React component is a function the React reconciler calls; nothing outside React can mount it. Lit produces custom elements, real entries in the browser's CustomElementRegistry, so the output is a tag like <my-badge> that works in an Angular template, a Rails ERB file, a plain HTML page, or a React tree, with no adapter beyond typing.

That portability is the whole reason large organisations pick Lit for design systems. Technically, LitElement extends ReactiveElement, which extends HTMLElement. ReactiveElement contributes the reactive property system and the batched update scheduler; LitElement adds a render() method whose returned template is committed by lit-html into the element's shadow root.

There is no virtual DOM. lit-html parses your tagged template literal once into an HTML <template>, records the positions of the dynamic bindings as 'parts', clones the template on first render, and on every later render writes only into those recorded positions. The trade-offs are real: you inherit shadow DOM, which means style encapsulation you did not ask for, forms that do not participate natively, and ARIA references that cannot cross the shadow boundary without ElementInternals. Interviewers ask this early to see whether you understand that Lit is a thin layer over browser primitives rather than a lighter React.

Key Points

  • Output is a real custom element registered in CustomElementRegistry
  • LitElement extends ReactiveElement extends HTMLElement
  • lit-html uses template cloning plus recorded parts, not a virtual DOM
  • Runtime is about 5KB min+gzip with no required build step
  • You inherit shadow DOM costs: forms, ARIA and global styles all change
Q2

How do you register a LitElement, and what naming rules does customElements.define enforce?

BasicCustom Elements

Answer

Registration happens through customElements.define(tagName, ClassRef). In TypeScript the @customElement('my-badge') decorator does exactly that call for you and nothing more. The tag name must contain at least one hyphen, must start with an ASCII lowercase letter, cannot contain uppercase letters, and cannot be one of the reserved hyphenated names such as annotation-xml or font-face.

So my-badge and gs-job-card are legal; mybadge and MyBadge are not, and the browser throws a SyntaxError before your class ever runs. Registration is global and single-shot. Calling define twice with the same name throws NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry': the name "my-badge" has already been used with this registry.

In production this almost never comes from a duplicated import in one bundle; it comes from two bundles on the same page each carrying their own copy of the component, which is routine in micro-frontends. The defensive pattern is a guard around define, but the better fix is deduplicating the dependency. Also remember upgrade order: if the tag appears in HTML before the module that defines it loads, the browser creates an HTMLUnknownElement-like placeholder and upgrades it when define runs, so any properties set on it before that point are shadowed by class fields unless you handle them.

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('gs-badge')
export class GsBadge extends LitElement {
  @property() label = 'New';

  render() {
    return html`<span class='badge'>${this.label}</span>`;
  }
}

// Plain JS equivalent, no decorators needed
// class GsBadge extends LitElement { ... }
// if (!customElements.get('gs-badge')) {
//   customElements.define('gs-badge', GsBadge);
// }

declare global {
  interface HTMLElementTagNameMap {
    'gs-badge': GsBadge;
  }
}
๐Ÿ’ก Pro Tip: Always add the HTMLElementTagNameMap declaration. Without it, document.querySelector('gs-badge') returns Element and you lose every property type in consuming code.
Q3

What is the difference between @property() and @state() in Lit?

BasicReactive Properties

Answer

Both declare a reactive property: assigning to it schedules an update. The difference is the public contract. @property() creates part of the element's API and, by default, observes a matching attribute. Lit lowercases the property name to derive the attribute (firstName becomes firstname unless you pass attribute: 'first-name'), converts incoming attribute strings using the declared type (String, Number, Boolean, Array, Object), and re-renders. @state() declares internal state: no attribute is observed, no attribute is reflected, and the property is marked as internal in tooling such as the custom elements manifest analyzer, so it does not show up in generated docs or framework wrappers.

Under the hood @state() is simply @property({ state: true, attribute: false }). Two behaviours trip people up. First, type: Boolean uses attribute presence, so disabled="false" in HTML still sets the property to true; only removing the attribute sets it false.

Second, attributes are strings, so type: Object and type: Array run JSON.parse on the attribute value, which throws on malformed input and is almost never what you want for real data. Pass complex data through property bindings instead. Interviewers use this question to check whether you treat attributes as a serialization boundary or as the same thing as properties.

import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

@customElement('gs-counter')
export class GsCounter extends LitElement {
  // Public API, observed as the 'start-at' attribute
  @property({ type: Number, attribute: 'start-at' }) startAt = 0;

  // Public boolean flag: presence based
  @property({ type: Boolean, reflect: true }) disabled = false;

  // Internal only, no attribute, hidden from generated docs
  @state() private count = 0;

  connectedCallback() {
    super.connectedCallback();
    this.count = this.startAt;
  }

  render() {
    return html`
      <button ?disabled=${this.disabled} @click=${() => this.count++}>
        ${this.count}
      </button>
    `;
  }
}

Key Points

  • @state() is @property({ state: true, attribute: false })
  • type: Boolean is presence based; disabled='false' still means true
  • type: Object and type: Array JSON.parse the attribute string
  • Attributes are a string serialization boundary, not a data channel
Q4

Explain the four binding syntaxes in a lit-html template and when each one applies.

BasicTemplates

Answer

lit-html decides binding behaviour from the character immediately before the expression. A bare name=${value} is an attribute binding: the value is stringified and passed to setAttribute, so an object becomes the string [object Object]. A dot prefix, .prop=${value}, is a property binding: Lit assigns element.prop = value directly, preserving objects, arrays, functions and Dates.

A question mark, ?attr=${value}, is a boolean attribute binding: truthy calls setAttribute with an empty string, falsy calls removeAttribute. An at sign, @event=${handler}, adds an event listener; Lit calls addEventListener once and swaps the internal handler reference on later renders, so passing an inline arrow function does not churn listeners. There is also a rarely used ${} in element position for directives such as ref().

The single most common bug in a Lit codebase is writing items=${this.rows} instead of .items=${this.rows} when passing an array to a child custom element. The child receives the string [object Object], your loop renders nothing, and there is no warning. If the child declared items with type: Array, Lit will try to JSON.parse that string and throw a SyntaxError instead, which is at least louder. When reviewing Lit code, scanning for missing dots on child-element bindings finds real bugs fast.

render() {
  return html`
    <!-- attribute: value is stringified -->
    <img src=${this.avatarUrl} alt=${this.name}>

    <!-- property: object survives intact -->
    <gs-job-card .job=${this.job} .tags=${this.tags}></gs-job-card>

    <!-- boolean attribute: present or absent -->
    <button ?disabled=${!this.isValid}>Apply</button>

    <!-- event listener -->
    <input @input=${(e: InputEvent) => (this.query = (e.target as HTMLInputElement).value)}>

    <!-- WRONG: renders tags="[object Object]" -->
    <gs-job-card tags=${this.tags}></gs-job-card>
  `;
}
๐Ÿ’ก Pro Tip: Bindings must cover a whole attribute value. class="btn ${this.variant}" works, but a directive like classMap cannot be used in a partial binding; it needs class=${classMap(...)}.
Q5

How does static styles work, and why is it better than a <style> tag inside render()?

BasicStyling

Answer

static styles is a class-level array of CSSResult objects produced by the css tagged template. Lit turns those into CSSStyleSheet instances once per class and attaches them to every instance's shadow root through adoptedStyleSheets. The cost is paid once no matter how many instances you create, and the browser can share the parsed stylesheet across all of them.

Putting a <style> block inside render() instead means each instance gets its own <style> node that the engine parses separately, and the styles are re-evaluated as part of every template commit. On a page with a few hundred rows that difference is measurable. The css tag also acts as a safety boundary: it only accepts nested CSSResult values or numbers in expressions, so you cannot accidentally interpolate user input into a stylesheet.

When you genuinely need a dynamic value, wrap it with unsafeCSS, and only for values you control. Two practical rules follow. Anything that varies per instance should be a CSS custom property read inside static styles, not a separately generated stylesheet, because a new css template per instance defeats the sharing. And remember that :host styles have very low specificity, so a single class selector applied from inside the component overrides them, which is exactly what makes :host a good place for defaults that consumers can override.

import { LitElement, html, css, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';

const BRAND = unsafeCSS('#1a73e8');

@customElement('gs-chip')
export class GsChip extends LitElement {
  static styles = css`
    :host {
      display: inline-flex;
      --chip-bg: ${BRAND};
      border-radius: 999px;
      padding: 4px 12px;
      background: var(--chip-bg);
      color: #fff;
    }
    :host([hidden]) { display: none; }
    :host(:focus-visible) { outline: 2px solid currentColor; }
  `;

  @property() label = '';

  render() {
    return html`${this.label}`;
  }
}

Key Points

  • static styles compiles once per class and uses adoptedStyleSheets
  • css`` only allows nested CSSResult or number interpolation
  • unsafeCSS is the escape hatch and must never take user input
  • Always add :host { display: ... }, custom elements are inline by default
Q6

How do you make a Lit component render into the light DOM, and what do you lose?

BasicShadow DOM

Answer

Override createRenderRoot() and return this. Lit then commits the template into the element itself rather than into an attached shadow root. This is the standard escape hatch when a component must inherit page-level CSS: Tailwind utility classes, a Bootstrap theme, or an existing global stylesheet that the team is not going to rewrite.

It is also what you do when the element must participate in a parent's native form or when a third-party script needs to query into its children. What you lose is significant. static styles is ignored entirely, because there is no shadow root to adopt the stylesheet into, so your component styles must move to a global sheet or be applied through classes. Slots stop working, since <slot> only projects inside shadow roots, so children passed in the markup are wiped out by the first render unless you capture them yourself. ::part() and ::slotted() are meaningless.

Event retargeting no longer happens, so composed: false events now escape. And any id in your template becomes a global id on the page, which reintroduces collisions. A middle ground worth mentioning in interviews: return a shadow root created with open mode but also adopt the document's stylesheet into it, which gives you global styles while keeping encapsulation for everything else.

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('gs-plain')
export class GsPlain extends LitElement {
  // Render into the light DOM: global CSS now applies
  protected createRenderRoot() {
    return this;
  }

  render() {
    return html`<div class='rounded bg-slate-100 p-4'>Styled by Tailwind</div>`;
  }
}

// Middle ground: keep shadow DOM but adopt the page stylesheet
@customElement('gs-hybrid')
export class GsHybrid extends LitElement {
  protected createRenderRoot() {
    const root = super.createRenderRoot() as ShadowRoot;
    root.adoptedStyleSheets = [...document.adoptedStyleSheets, ...root.adoptedStyleSheets];
    return root;
  }
}
๐Ÿ’ก Pro Tip: If you go light DOM, delete static styles rather than leaving it in place. Leaving dead styles in the class is the most common reason a reviewer cannot tell whether the escape hatch was deliberate.
Q7

How do slots work in Lit, and how do you read the nodes a consumer passed in?

BasicSlots

Answer

A <slot> in your shadow template is a projection point for light DOM children. Unnamed <slot> catches everything without a slot attribute; <slot name="header"> catches children with slot="header". Content inside the slot tag is fallback that renders only when nothing is assigned.

Crucially, slotted nodes are never moved. They stay children of the host in the light DOM and are only rendered at the slot position, which means the consumer's page-level CSS still styles them, and your shadow styles do not, except through the ::slotted() pseudo-element which reaches exactly one level and only matches top-level assigned nodes, not their descendants. To read what was assigned, use the @queryAssignedElements decorator with a slot name and optional selector filter, or @queryAssignedNodes when you also need text nodes.

Both are getters that call slot.assignedElements() on access, so they are always current. If you need to react to changes, listen for the slotchange event, which fires when the assigned set changes but not when an already-assigned element mutates internally. A classic interview follow-up is why a component that counts its slotted children in firstUpdated reports zero: the slot has not been assigned yet at that point in some upgrade orders, so counting belongs in a slotchange handler.

import { LitElement, html, css } from 'lit';
import { customElement, queryAssignedElements, state } from 'lit/decorators.js';

@customElement('gs-tabs')
export class GsTabs extends LitElement {
  static styles = css`
    ::slotted(button) { font: inherit; }
    ::slotted(button[selected]) { font-weight: 700; }
  `;

  @queryAssignedElements({ slot: 'tab', selector: 'button' })
  private tabs!: HTMLButtonElement[];

  @state() private count = 0;

  private onSlotChange() {
    this.count = this.tabs.length;
  }

  render() {
    return html`
      <div role='tablist'><slot name='tab' @slotchange=${this.onSlotChange}></slot></div>
      <slot>No panel content</slot>
      <p>${this.count} tabs</p>
    `;
  }
}

Key Points

  • Slotted nodes stay in the light DOM and keep page-level styling
  • ::slotted() matches only top-level assigned nodes, not descendants
  • @queryAssignedElements is a live getter over slot.assignedElements()
  • Use slotchange, not firstUpdated, to react to assigned content
Q8

What do the nothing and noChange sentinels do, and how do they differ from undefined?

BasicTemplates

Answer

These are two sentinel values exported from lit that control what a binding commits. nothing means render an empty result. In child position it produces no nodes, which is the same as undefined or null there. In attribute position the behaviour diverges sharply: nothing removes the attribute entirely, while undefined and null render as an empty string.

That is why the ifDefined directive exists; ifDefined(x) is exactly x ?? nothing, and you reach for it whenever an empty attribute is not equivalent to an absent one. The classic case is an image: src=${undefined} produces src="", which the browser resolves against the current URL and fetches the page itself, showing a broken image and adding a spurious request in your logs. src=${ifDefined(this.url)} leaves the attribute off. noChange is different: it tells the part to skip committing altogether and leave whatever is currently in the DOM. Returning noChange from a directive is how you implement conditional no-ops, and it is also useful in the very first render of a directive that wants to keep server-rendered content untouched. In interviews, being able to state that nothing removes an attribute while undefined empties it is the answer that separates people who have shipped Lit from people who have read the docs once.

import { html, nothing, noChange } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';

render() {
  return html`
    <!-- attribute is REMOVED when url is undefined -->
    <img src=${ifDefined(this.url)} alt=${this.alt ?? nothing}>

    <!-- WRONG: renders src="" and refetches the current page -->
    <img src=${this.url}>

    <!-- conditional subtree -->
    ${this.error ? html`<p role='alert'>${this.error}</p>` : nothing}
  `;
}

// noChange inside a directive: leave the DOM exactly as it is
// return this.shouldSkip ? noChange : newValue;
๐Ÿ’ก Pro Tip: aria-label=${this.label ?? nothing} is the correct pattern for optional ARIA attributes. An empty aria-label is not the same as no aria-label to a screen reader.
Q9

What problems do classMap and styleMap solve, and what are their placement rules?

BasicDirectives

Answer

Both are directives that let you drive an attribute from an object instead of building a string. classMap takes an object whose keys are class names and whose values are booleans, and it applies or removes each class individually. styleMap takes an object of CSS properties (camelCase or quoted kebab-case keys, custom properties as quoted strings) and sets them on the element's inline style. The reason they matter is that both operate incrementally: classMap only touches the classes it manages, so a class added imperatively elsewhere, or by a third-party script, survives a re-render. Building class="a ${b}" by hand blows those away every time.

The rules are strict. Each directive must be the entire value of its binding, and classMap can only be used on a class attribute while styleMap can only be used on style. Break either rule and Lit throws at render time with a message stating that the directive must be used in the class attribute and must be the only part in the attribute value.

If you need a fixed class alongside dynamic ones, put the fixed class in the map with a literal true value, or better, put it in your static styles selector. One more practical point: styleMap sets inline styles, which beat everything short of !important, so it is the wrong tool for theming and the right tool for computed geometry such as a progress bar width.

import { html } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';

render() {
  const classes = {
    chip: true,
    'chip--selected': this.selected,
    'chip--disabled': this.disabled,
  };

  const bar = {
    width: `${this.percent}%`,
    backgroundColor: this.percent > 80 ? 'crimson' : 'seagreen',
    '--chip-radius': '8px',
  };

  return html`
    <span class=${classMap(classes)}>${this.label}</span>
    <div class='track'><div class='fill' style=${styleMap(bar)}></div></div>
  `;
}

// Throws: classMap must be the only part in the attribute value
// <span class='chip ${classMap(classes)}'></span>
Q10

How do you query elements inside the shadow root, and when is it safe to do so?

BasicDOM Access

Answer

Lit ships four decorators in lit/decorators.js. @query('#input') defines a getter that runs this.renderRoot.querySelector('#input') on every access. @queryAll('.row') returns a NodeList. @queryAsync('#input') returns a promise that awaits this.updateComplete first and then queries, which is the safe version when you are not sure the node exists yet. @query also accepts a second cache argument, @query('#input', true), which memoises the result after the first successful lookup; only use it for nodes that are guaranteed to exist for the element's whole life, because a cached miss is permanent. Timing is the real content of this question. The shadow root has no content before the first render completes, so querying in the constructor or in connectedCallback returns null.

The earliest safe hook is firstUpdated(), which runs after the first commit. If the node is behind a conditional in the template, even firstUpdated is not enough; the node may appear three updates later, so query it in updated() or await this.updateComplete at the call site. Also note that these queries never cross into slotted light DOM content, use @queryAssignedElements for that, and they do not descend into nested components' shadow roots, which is deliberate encapsulation and the reason end-to-end tests need deep-piercing selectors.

import { LitElement, html } from 'lit';
import { customElement, query, queryAll, queryAsync } from 'lit/decorators.js';

@customElement('gs-search')
export class GsSearch extends LitElement {
  @query('#q') private input!: HTMLInputElement;
  @queryAll('li') private items!: NodeListOf<HTMLLIElement>;
  @queryAsync('#q') private inputAsync!: Promise<HTMLInputElement>;

  constructor() {
    super();
    console.log(this.input); // null: nothing rendered yet
  }

  protected firstUpdated() {
    this.input.focus(); // safe: first commit is done
  }

  async focusLater() {
    (await this.inputAsync).select();
  }

  render() {
    return html`<input id='q'><ul><li>one</li></ul>`;
  }
}

Key Points

  • @query is a live getter; it re-queries on every property access
  • Shadow root is empty until the first update completes
  • firstUpdated is the earliest safe hook, updated for conditional nodes
  • Queries never cross slot boundaries or nested shadow roots
Q11

Walk through Lit's update lifecycle in order, from a property assignment to updateComplete resolving.

BasicLifecycle

Answer

Assigning to a reactive property calls the generated setter, which runs hasChanged(newValue, oldValue). The default is Lit's notEqual, a strict inequality check with NaN handling. If it returns false, nothing happens at all.

If true, requestUpdate() records the property and its old value in the changedProperties map and, if no update is already pending, schedules performUpdate on a microtask. Because it is a microtask, every property you set in the same synchronous block coalesces into a single render, which is why you can assign ten properties in a loop without ten renders. When the microtask runs: shouldUpdate(changedProperties) is called and returning false aborts the cycle, then willUpdate(changedProperties) runs, then update(changedProperties), which reflects any reflect: true properties to attributes and calls render(), then lit-html commits the returned template into the render root.

After the DOM is committed, firstUpdated(changedProperties) runs exactly once for the element's life, then updated(changedProperties) runs on every cycle, and finally the updateComplete promise resolves. The distinction that matters in practice: setting a reactive property inside willUpdate is folded into the current cycle at no cost, while setting one inside updated schedules a whole second cycle, and Lit logs a dev-mode warning about scheduling an update after an update completed. That warning is a real performance smell and interviewers ask about it directly.

protected shouldUpdate(changed: PropertyValues<this>) {
  return !changed.has('_scrollTop'); // skip renders for scroll noise
}

protected willUpdate(changed: PropertyValues<this>) {
  // Derive state cheaply: no extra render cycle
  if (changed.has('jobs') || changed.has('query')) {
    this.filtered = this.jobs.filter((j) => j.title.includes(this.query));
  }
}

protected firstUpdated() {
  this.observer = new ResizeObserver(() => this.requestUpdate());
  this.observer.observe(this);
}

protected updated(changed: PropertyValues<this>) {
  // Reading layout here is fine; writing reactive props costs a second cycle
  if (changed.has('open') && this.open) this.dialog.showModal();
}

async commit() {
  this.open = true;
  await this.updateComplete; // DOM now reflects open === true
}
๐Ÿ’ก Pro Tip: Derive in willUpdate, measure in updated. If you find yourself assigning reactive properties inside updated, that logic almost always belongs in willUpdate.
Q12

How do you dispatch a custom event from a Lit component so a parent outside the shadow root receives it?

BasicEvents

Answer

Use this.dispatchEvent(new CustomEvent('gs-select', { detail, bubbles: true, composed: true })). Both flags matter and they do different things. bubbles: true lets the event travel up the ancestor chain. composed: true lets it cross shadow boundaries; without it the event stops at the shadow root it was dispatched in, so a parent component listening on your host tag never sees it. Note that all of Lit's own bindings are ordinary DOM listeners, so @gs-select=${this.onSelect} in a parent template works only if the event actually reaches that element.

Composed events get retargeted as they cross each boundary: event.target is rewritten to the host element so that outside observers never see your internal nodes, while event.composedPath() still returns the full path including shadow internals. That retargeting is exactly what you want for encapsulation and exactly what confuses people debugging why event.target is the custom element rather than the button inside it. Naming convention matters in reviews: prefix events with your component or design-system namespace, gs-select rather than select, so you never collide with a native event name.

Never reuse a native name with different semantics, because a stray listener for change will fire on both. Finally, do not put class instances with methods in detail if the component may be server-rendered or crossed with structured cloning; keep detail to plain serialisable data.

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

export interface GsSelectDetail { id: string; label: string }

@customElement('gs-option')
export class GsOption extends LitElement {
  @property() optionId = '';
  @property() label = '';

  private select() {
    this.dispatchEvent(
      new CustomEvent<GsSelectDetail>('gs-select', {
        detail: { id: this.optionId, label: this.label },
        bubbles: true,
        composed: true,
      }),
    );
  }

  render() {
    return html`<button @click=${this.select}>${this.label}</button>`;
  }
}

declare global {
  interface HTMLElementEventMap {
    'gs-select': CustomEvent<GsSelectDetail>;
  }
}

Key Points

  • composed: true is required to cross a shadow boundary, bubbles is not enough
  • Retargeting rewrites event.target to the host; composedPath() keeps the truth
  • Namespace event names to avoid colliding with native events
  • Augment HTMLElementEventMap so listeners are typed in consuming code
Q13

What is the minimum tooling to build and ship a Lit component in 2026?

BasicTooling

Answer

Lit needs no build step to run: a browser can load the ESM straight from node_modules if you serve bare specifiers resolved, which is what @web/dev-server does with its node-resolve flag. For a real project the common 2026 setups are Vite for apps and Rollup or the Vite library mode for publishing packages. Scaffolding usually comes from npm init @open-wc, which generates a Lit component, a Web Test Runner config, and linting; or you start from vite create with the lit or lit-ts template.

On the TypeScript side, three settings decide whether decorators work at all: experimentalDecorators, emitDecoratorMetadata (not needed by Lit, skip it) and useDefineForClassFields, which must be false if you use experimentalDecorators with a target of ES2022 or later. For quality gates, add lit-analyzer or eslint-plugin-lit, which catch template-level mistakes a normal type checker cannot see: unknown tag names, missing property dots, unclosed tags, and invalid event names. Finally, run the custom elements manifest analyzer as part of the build.

It emits custom-elements.json describing every tag, property, event, slot and CSS custom property, which is what drives editor autocomplete, Storybook docs, and the generation of React or Angular wrappers. Teams that skip the manifest end up hand-maintaining three copies of their API surface.

# Scaffold
npm init @open-wc          # component + Web Test Runner + lint
# or
npm create vite@latest my-ds -- --template lit-ts

npm i lit
npm i -D @custom-elements-manifest/analyzer @web/test-runner lit-analyzer

# Emit the API manifest consumed by editors, docs and wrappers
npx cem analyze --litelement --globs 'src/**/*.ts'

# tsconfig.json essentials for experimental decorators
# {
#   "compilerOptions": {
#     "target": "ES2022",
#     "experimentalDecorators": true,
#     "useDefineForClassFields": false,
#     "moduleResolution": "bundler"
#   }
# }
๐Ÿ’ก Pro Tip: Set sideEffects carefully in package.json. customElements.define is a side effect, so a blanket "sideEffects": false lets bundlers drop your registration and your tags silently never upgrade.
Q14

What does the lit dev-mode build warn about, and how do you ship the production build?

BasicBuild

Answer

The lit package publishes two variants selected by the development export condition. The dev build adds runtime checks and logs the banner Lit is in dev mode. Not recommended for production!

It also warns about specific mistakes: scheduling an update after an update completed, changing a property in updated, multiple versions of Lit loaded on the page, invalid property declarations, and directives used in the wrong part type. Those warnings are the fastest debugging tool Lit gives you, so keep dev mode on locally. In production you want the other build, which strips the checks and is meaningfully smaller.

How you select it depends on the bundler. Rollup with @rollup/plugin-node-resolve takes an exportConditions array; put development in it for dev builds and leave it out for production. Vite handles this through its own dev and build modes plus resolve.conditions.

If you see the dev banner in a production bundle, the usual cause is a tool that adds development to the resolve conditions globally, or a monorepo where one package resolves lit differently from the rest. The multiple versions warning deserves special attention because it is not cosmetic: two copies of lit means two independent template caches and two ReactiveElement classes, so instanceof checks fail and directives from one copy throw when used in templates from the other.

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';

const dev = process.env.NODE_ENV !== 'production';

export default {
  input: 'src/index.ts',
  plugins: [
    resolve({
      // include 'development' ONLY for local builds
      exportConditions: dev ? ['development'] : [],
    }),
  ],
};

// Quick check in the browser console of a deployed build:
// there should be no 'Lit is in dev mode' banner, and
// document.querySelectorAll('*').length should not reveal
// two different ReactiveElement constructors.

Key Points

  • lit ships dev and prod builds behind the development export condition
  • Dev mode warns about updates-after-update and duplicate Lit copies
  • Rollup selects it via exportConditions, Vite via resolve.conditions
  • Two copies of Lit break instanceof and cross-copy directives
Q15

Why does this.items.push(newItem) not re-render, and what are the correct fixes?

IntermediateReactive Properties

Answer

Because Lit's dirty check compares references, not contents. Every reactive property has a hasChanged function whose default is notEqual, roughly (value, old) => old !== value || (old === old) === (value === value) is false, which reduces to strict inequality with correct NaN handling. Mutating an array in place leaves the reference identical, so the setter is never even invoked, no changedProperties entry is recorded, and no update is scheduled.

There are three legitimate fixes and one anti-pattern. Fix one, treat the property as immutable: this.items = [...this.items, newItem]. This is the default recommendation because it also makes changedProperties.get('items') meaningful for diffing.

Fix two, mutate and then call this.requestUpdate('items', oldValue) explicitly, which is the right choice when copying is genuinely expensive, for example a ten thousand row grid where you append one row. Passing the old value matters if any downstream logic reads changedProperties. Fix three, supply a custom hasChanged that does a shallow or deep comparison, useful when a property is reassigned frequently with structurally identical objects and you want to suppress renders. The anti-pattern is calling this.requestUpdate() with no arguments everywhere, which works but forces a full re-render and hides which data actually changed, and it makes shouldUpdate and willUpdate useless because changedProperties is empty.

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('gs-list')
export class GsList extends LitElement {
  @property({ attribute: false }) items: string[] = [];

  // Custom dirty check: ignore reassignments that are structurally equal
  @property({
    attribute: false,
    hasChanged: (a: { id: string }, b: { id: string }) => a?.id !== b?.id,
  })
  selected?: { id: string };

  addImmutable(item: string) {
    this.items = [...this.items, item]; // reference changes, update scheduled
  }

  addInPlace(item: string) {
    const old = this.items.slice();
    this.items.push(item);          // no reference change
    this.requestUpdate('items', old); // tell Lit explicitly
  }

  render() {
    return html`<ul>${this.items.map((i) => html`<li>${i}</li>`)}</ul>`;
  }
}

Key Points

  • Default hasChanged is notEqual, a strict inequality reference check
  • Immutable reassignment is the default fix and keeps diffing usable
  • requestUpdate('prop', oldValue) is correct when copying is too expensive
  • Bare requestUpdate() empties changedProperties and defeats shouldUpdate
Q16

When should you use the repeat() directive instead of Array.prototype.map in a template?

IntermediateDirectives

Answer

Plain .map() produces an array of TemplateResults that lit-html commits positionally. Item zero in the new array updates the DOM that item zero produced last time, item one updates item one's DOM, and so on. If the list is reordered, every position gets new data written into existing nodes.

That is actually the fastest strategy when the DOM is stateless, because Lit only touches the changed bindings and never moves nodes. It becomes wrong the moment a row holds DOM state that is not derived from your data: an <input> the user typed into, a focused element, a running CSS transition, a video element, a nested component with internal @state. Reordering then leaves that state attached to the wrong item. repeat(items, keyFn, template) fixes this by tracking each item's DOM by key and physically moving nodes when the order changes, so per-row state follows the row.

The cost is bookkeeping, a key map plus node moves, so repeat is measurably slower than map for pure appends and simple text rows. The rule of thumb interviewers want: use map by default, use repeat with a stable key when rows hold state or the list reorders. And repeat without a key function silently degrades to index keying, which is the same as map plus overhead, so a missing keyFn is a code review flag. keyed() is the related single-value directive: it forces the subtree to be recreated when a key changes.

import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { keyed } from 'lit/directives/keyed.js';

render() {
  return html`
    <!-- stateless rows: map is fine and faster -->
    <ul>${this.tags.map((t) => html`<li>${t}</li>`)}</ul>

    <!-- rows hold input state and can be reordered: keyed repeat -->
    <ul>
      ${repeat(
        this.candidates,
        (c) => c.id,
        (c) => html`<li><input .value=${c.note}> ${c.name}</li>`,
      )}
    </ul>

    <!-- force a full teardown when the profile changes -->
    ${keyed(this.profileId, html`<gs-profile-editor .id=${this.profileId}></gs-profile-editor>`)}
  `;
}
๐Ÿ’ก Pro Tip: If a reorder makes text update correctly but focus or typed input jumps to the wrong row, you are using map where you need a keyed repeat.
Q17

What is changedProperties, what does it contain, and how do you read the new value?

IntermediateLifecycle

Answer

changedProperties is a Map passed to shouldUpdate, willUpdate, update, firstUpdated and updated. Its keys are the names of reactive properties that changed in this cycle, and, this is the part candidates get wrong, its values are the OLD values, not the new ones. The new value is simply this.propName, because the setter has already run by the time any lifecycle hook sees the map.

So the idiomatic diff is: if (changed.has('userId')) { const previous = changed.get('userId'); const current = this.userId; }. In TypeScript, type it as PropertyValues<this> from lit so that changed.get returns the property's declared type rather than unknown. Two behaviours are worth knowing.

On the very first update, changedProperties contains an entry for every reactive property that has a value set, with the old value being undefined, which is why firstUpdated receives a fully populated map. And requestUpdate('name', oldValue) is what seeds an entry manually, which is exactly what you do after mutating an object in place. A frequent production bug is fetching data in updated whenever changed.has('userId') is true without comparing old and new: on the first update that condition is true even though nothing meaningfully changed, so the component fires a duplicate request on mount. Guard with changed.get('userId') !== undefined, or move the fetch to a Task from @lit/task where argument identity handles it for you.

import { LitElement, PropertyValues, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

@customElement('gs-profile')
export class GsProfile extends LitElement {
  @property() userId = '';
  @state() private profile?: { name: string };

  protected willUpdate(changed: PropertyValues<this>) {
    if (changed.has('userId')) {
      const previous = changed.get('userId'); // OLD value
      const current = this.userId;            // NEW value
      if (previous !== undefined && previous !== current) {
        this.profile = undefined; // clear stale data before the fetch
      }
    }
  }

  protected updated(changed: PropertyValues<this>) {
    if (changed.has('userId') && this.userId) {
      void this.load(this.userId);
    }
  }

  render() {
    return html`<h2>${this.profile?.name ?? 'Loading'}</h2>`;
  }
}

Key Points

  • Map values are the OLD values; read this.prop for the new one
  • First update populates the map for every set property, old value undefined
  • Type it as PropertyValues<this> for typed get() results
  • Guard against the first-update entry or you double-fetch on mount
Q18

What does await this.updateComplete guarantee, and why can it resolve before nested components are done?

IntermediateLifecycle

Answer

updateComplete is a promise that resolves after the element's own update cycle has committed to the DOM and updated() has run. It resolves to a boolean: true if no further update was scheduled during the cycle, false if something inside updated or firstUpdated requested another one. That boolean is the mechanism for detecting update loops, and ignoring it is why people write await el.updateComplete twice in tests and cannot explain why.

The important limitation is scope: it only tracks this element. Child custom elements in your template receive their property bindings during your commit, which schedules their own updates on a later microtask, so when your updateComplete resolves the children may still be pending. In tests that shows up as a measurement of the wrong layout or a query that finds an empty child.

The supported fix is overriding getUpdateComplete() to await the children you care about, calling super.getUpdateComplete() first and then awaiting each child's updateComplete. In an integration test, the pragmatic alternative is awaiting a microtask flush or using elementUpdated from @open-wc/testing on the specific child. This is one of the most reliable senior-level filters in a Lit interview because it forces you to describe the scheduler rather than repeat documentation: the update queue is per element, not per tree, and there is no framework-wide flush.

import { LitElement, html } from 'lit';
import { customElement, query } from 'lit/decorators.js';

@customElement('gs-shell')
export class GsShell extends LitElement {
  @query('gs-chart') private chart!: LitElement;

  // Make the parent's updateComplete wait for the child too
  protected async getUpdateComplete() {
    const done = await super.getUpdateComplete();
    await this.chart?.updateComplete;
    return done;
  }

  render() {
    return html`<gs-chart .data=${this.data}></gs-chart>`;
  }
}

// In a test
// el.data = rows;
// const settled = await el.updateComplete;
// expect(settled).to.be.true; // false means something re-scheduled
๐Ÿ’ก Pro Tip: If await el.updateComplete resolves to false in a test, do not just await again. Find what wrote a reactive property inside updated, that is a real second render on every interaction in production.
Q19

What is a reactive controller, and when do you choose one over a mixin or a base class?

IntermediateReactive Controllers

Answer

A reactive controller is a plain object implementing any of hostConnected, hostDisconnected, hostUpdate, hostUpdated. You register it with host.addController(this), usually from the controller's own constructor, and Lit calls those hooks at the matching points in the host's lifecycle. Controllers can call host.requestUpdate() to trigger a render.

They exist to package a piece of stateful behaviour with its own setup and teardown, and the teardown is the point: hostDisconnected fires automatically when the element leaves the DOM, so an interval, a media query listener, a ResizeObserver or a WebSocket subscription cannot leak by omission the way it can when you hand-write connectedCallback and disconnectedCallback. Compared with a mixin, a controller composes without touching the prototype chain, so two controllers never collide on a method name, whereas two mixins that both define a handleKeydown do. Compared with a base class, a controller lets one element use three unrelated behaviours; single inheritance does not.

Choose a mixin only when you must add or override actual element API such as a static property declaration or a lifecycle override, and a base class only for a design system's shared foundation. One detail to mention: a controller added after the host has already connected does not get a retroactive hostConnected call in older versions, so add controllers in the constructor or field initialiser. Both @lit/task and @lit/context are implemented as controllers.

import { ReactiveController, ReactiveControllerHost } from 'lit';

export class MediaQueryController implements ReactiveController {
  matches = false;
  private mql: MediaQueryList;
  private onChange = () => {
    this.matches = this.mql.matches;
    this.host.requestUpdate();
  };

  constructor(private host: ReactiveControllerHost, query: string) {
    this.mql = window.matchMedia(query);
    this.matches = this.mql.matches;
    host.addController(this);
  }

  hostConnected() {
    this.mql.addEventListener('change', this.onChange);
  }

  hostDisconnected() {
    this.mql.removeEventListener('change', this.onChange); // no leak, ever
  }
}

// Usage inside a component
// private mobile = new MediaQueryController(this, '(max-width: 640px)');
// render() { return html`${this.mobile.matches ? 'compact' : 'wide'}`; }

Key Points

  • Hooks: hostConnected, hostDisconnected, hostUpdate, hostUpdated
  • Automatic teardown on disconnect is the main safety win
  • Composable without prototype collisions, unlike mixins
  • Mixins only when you need real element API or lifecycle overrides
Q20

How does the Task controller from @lit/task manage async work tied to property changes?

IntermediateAsync State

Answer

Task is a reactive controller that owns the four states every async fetch has: initial, pending, complete and error. You construct it with a task function and an args function. Before every host update, Task calls args(), shallow-compares the returned array against the previous one, and if any entry changed it runs the task function with those arguments.

The result drives task.status (a TaskStatus enum), task.value and task.error, and task.render({ initial, pending, complete, error }) lets you map each state to a template in one expression. This removes the standard hand-rolled trio of loading, data and error @state fields plus the fetch call in updated, and more importantly it removes the race those hand-rolled versions always have. Task tracks which run is current and discards results from superseded runs, so a slow response for query 'react' cannot overwrite a fast response for 'react native'.

The task function also receives an options object containing an AbortSignal, so pass it to fetch and the previous request is genuinely cancelled rather than just ignored. Set autoRun: false when the task should only fire on an explicit user action, then call task.run() yourself. Two gotchas: args must return a new array each call but the entries are compared shallowly, so passing an object literal reruns the task on every update; and throwing inside the task is how you populate error, returning a rejected promise works too, but returning an error object does not.

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Task, TaskStatus } from '@lit/task';

@customElement('gs-job-search')
export class GsJobSearch extends LitElement {
  @property() query = '';
  @property({ type: Number }) page = 1;

  private search = new Task(this, {
    task: async ([query, page], { signal }) => {
      if (!query) return [];
      const res = await fetch(`/api/jobs?q=${encodeURIComponent(query)}&page=${page}`, { signal });
      if (!res.ok) throw new Error(`Search failed: ${res.status}`);
      return (await res.json()) as Array<{ id: string; title: string }>;
    },
    args: () => [this.query, this.page] as const,
  });

  render() {
    return this.search.render({
      initial: () => html`<p>Type to search jobs</p>`,
      pending: () => html`<gs-spinner></gs-spinner>`,
      error: (e) => html`<p role='alert'>${(e as Error).message}</p>`,
      complete: (jobs) => html`<ul>${jobs.map((j) => html`<li>${j.title}</li>`)}</ul>`,
    });
  }
}
๐Ÿ’ก Pro Tip: Always thread the AbortSignal into fetch. Without it, a fast typist leaves ten in-flight requests open and your API rate limit, not your UI, becomes the failure point.
Q21

How does @lit/context pass data down a tree without prop drilling, and what are its limits?

IntermediateContext

Answer

@lit/context implements the community context protocol, which is a DOM event contract rather than a framework feature. A consumer dispatches a composed, bubbling context-request event carrying a context key and a callback. The nearest ancestor that provides that key catches the event, calls the callback with the current value, and, if the consumer asked to subscribe, keeps the callback to call again whenever the value changes.

Because it rides on DOM events, it works across shadow boundaries and even across components written in different libraries, which is the point: a Lit provider can feed a non-Lit consumer as long as both speak the protocol. In practice you create a typed key with createContext<Theme>('theme'), then use the @provide({ context }) decorator on a property in the ancestor and @consume({ context, subscribe: true }) on a property in the descendant. The consumer property is set automatically and, because it is reactive, the consumer re-renders.

The limits matter in interviews. Resolution is by DOM ancestry, so an element that is not yet connected finds no provider; consumers request again on connect. There is no default value unless the provider exists, so type the property as possibly undefined and design a fallback.

With subscribe: false you get a one-time snapshot and later provider changes are invisible. And overuse turns context into an implicit global: a component that consumes five contexts is untestable in isolation, which is exactly what interviewers probe when they ask what you would put in context and what stays a property.

import { createContext, provide, consume } from '@lit/context';
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

export interface Theme { mode: 'light' | 'dark'; accent: string }
export const themeContext = createContext<Theme>('gs-theme');

@customElement('gs-theme-provider')
export class GsThemeProvider extends LitElement {
  @provide({ context: themeContext })
  @state()
  theme: Theme = { mode: 'light', accent: '#1a73e8' };

  toggle() {
    this.theme = { ...this.theme, mode: this.theme.mode === 'light' ? 'dark' : 'light' };
  }

  render() { return html`<slot></slot>`; }
}

@customElement('gs-themed-button')
export class GsThemedButton extends LitElement {
  @consume({ context: themeContext, subscribe: true })
  @property({ attribute: false })
  theme?: Theme;

  render() {
    return html`<button style='color:${this.theme?.accent ?? '#333'}'><slot></slot></button>`;
  }
}

Key Points

  • Built on a composed context-request DOM event, not a Lit-only channel
  • @provide on the ancestor, @consume with subscribe: true on the descendant
  • Resolution is by DOM ancestry, so disconnected elements find nothing
  • Always design a fallback: there is no value when no provider exists
Q22

How do attribute converters and reflect: true work, and where do they go wrong?

IntermediateReactive Properties

Answer

A converter controls translation between the attribute string and the property value. Pass converter: { fromAttribute, toAttribute } in the property declaration, or a single function which is treated as fromAttribute only. fromAttribute receives the raw string (or null when the attribute is removed) and returns the property value. toAttribute receives the property value and returns a string, or null to remove the attribute, and it is only consulted when reflect: true. reflect writes the property back out to the attribute during update(), before render, so the new attribute value is visible to CSS selectors in the same frame that the template commits. That is the legitimate reason to reflect: styling hooks such as :host([variant="danger"]) and accessibility state that assistive technology reads from the DOM.

Where it goes wrong: reflecting objects or arrays produces useless attribute strings, reflecting frequently changing values causes constant attribute mutation that invalidates style recalculation and floods MutationObservers, and asymmetric converters where fromAttribute(toAttribute(v)) does not equal v produce values that silently drift. Lit does protect you from the obvious infinite loop, it tracks that a property is currently reflecting and does not re-run fromAttribute for its own write, but it cannot protect you from a converter that is not a round trip. The other trap is that attributes are only read on upgrade and on attributeChangedCallback, so setting a property imperatively never updates the attribute unless reflect is on.

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

const csv = {
  fromAttribute: (value: string | null) =>
    value ? value.split(',').map((s) => s.trim()).filter(Boolean) : [],
  toAttribute: (value: string[]) => (value.length ? value.join(',') : null),
};

@customElement('gs-filter')
export class GsFilter extends LitElement {
  // <gs-filter skills='react, lit, node'>
  @property({ converter: csv, reflect: true }) skills: string[] = [];

  // Reflect for styling only: :host([variant='danger'])
  @property({ reflect: true }) variant: 'default' | 'danger' = 'default';

  // Never reflect this: object stringifies to [object Object]
  @property({ attribute: false }) job?: { id: string; title: string };

  render() {
    return html`${this.skills.length} filters`;
  }
}
๐Ÿ’ก Pro Tip: Reflect only what CSS or assistive technology needs to read. Every reflected write is a DOM mutation, and reflecting a value that changes on every keystroke is a real performance bug.
Q23

How do you let consumers theme a component whose styles are locked inside a shadow root?

IntermediateStyling

Answer

Shadow DOM blocks external selectors from reaching inside, so theming needs explicit holes that you design. There are four, in order of preference. First, CSS custom properties: they inherit through shadow boundaries, so a consumer setting --gs-button-bg on any ancestor reaches var(--gs-button-bg, #1a73e8) inside your styles.

Give every token a sensible fallback so the component works unthemed, and document the token list in the custom elements manifest. Second, ::part(): add part="label" to an internal node and a consumer writes gs-button::part(label) { letter-spacing: 0.02em }. Parts expose arbitrary properties, which is powerful but freezes that node's existence as public API; renaming or removing it is a breaking change.

Third, :host() and :host-context(): :host([variant='danger']) styles based on the host's own attributes, so consumers theme by setting attributes rather than CSS, which is the most stable contract of all. Fourth, adopting a shared stylesheet: export a CSSResult of design tokens from your package and include it in every component's static styles array, which gives internal consistency but no consumer control. The failure mode teams hit is skipping all four and telling consumers to use ::part on everything, which produces a design system with no encapsulation left. The interview answer is that theming is API design: custom properties for values, parts for the small set of nodes you commit to supporting, attributes for variants.

import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('gs-button')
export class GsButton extends LitElement {
  static styles = css`
    :host { display: inline-block; }
    button {
      background: var(--gs-button-bg, #1a73e8);
      color: var(--gs-button-fg, #fff);
      border-radius: var(--gs-button-radius, 6px);
      border: 0;
      padding: 8px 16px;
    }
    :host([variant='danger']) button { background: var(--gs-danger, #d93025); }
  `;

  @property({ reflect: true }) variant: 'default' | 'danger' = 'default';

  render() {
    return html`<button part='base'><span part='label'><slot></slot></span></button>`;
  }
}

// Consumer side
// gs-button { --gs-button-bg: #0b6b3a; }
// gs-button::part(label) { text-transform: uppercase; }

Key Points

  • CSS custom properties inherit through shadow boundaries, always give fallbacks
  • ::part() exposes a node as permanent public API, use it sparingly
  • :host([attr]) variants are the most stable theming contract
  • Document tokens and parts in custom-elements.json or nobody finds them
Q24

What problems do the live, guard, cache and until directives each solve?

IntermediateDirectives

Answer

Each addresses a specific failure of the default commit strategy. live() fixes the controlled-input problem. lit-html skips a commit when the new value equals the last value it wrote, but the user may have typed since then, so the DOM value and Lit's recorded value disagree and your reset silently does nothing. .value=${live(this.value)} compares against the element's current live DOM value instead, so it always corrects a diverged input. guard() skips re-rendering a subtree unless the dependency array changes by reference, which is how you avoid rebuilding an expensive template when unrelated properties update; it is the Lit equivalent of a memo boundary. cache() keeps the DOM of templates you switch away from, so alternating between two views does not tear down and rebuild each time; use it for tab panels where remount cost is high, and skip it where retained DOM would leak memory across hundreds of items. until() renders placeholder content until a promise resolves, taking multiple values in priority order, useful for lazy content when you do not want a full Task controller. The judgement interviewers look for: guard and cache are optimisations, so add them after measuring, whereas live is a correctness fix and belongs in every two-way bound input. Also note that all four are directives, so they only work inside a template expression and cannot be called as ordinary functions.

import { html } from 'lit';
import { live } from 'lit/directives/live.js';
import { guard } from 'lit/directives/guard.js';
import { cache } from 'lit/directives/cache.js';
import { until } from 'lit/directives/until.js';

render() {
  return html`
    <!-- correctness: resets a field the user has typed into -->
    <input .value=${live(this.draft)} @input=${this.onInput}>

    <!-- optimisation: rebuild only when rows identity changes -->
    ${guard([this.rows], () => html`<gs-heavy-table .rows=${this.rows}></gs-heavy-table>`)}

    <!-- retain DOM while switching tabs -->
    ${cache(this.tab === 'jobs'
      ? html`<gs-jobs></gs-jobs>`
      : html`<gs-profile></gs-profile>`)}

    <!-- placeholder until the promise settles -->
    ${until(this.detailsPromise, html`<gs-skeleton></gs-skeleton>`)}
  `;
}
๐Ÿ’ก Pro Tip: If a form's Reset button visibly does nothing the second time it is pressed, the fix is almost always wrapping the value binding in live().
Q25

How do you make a Lit component participate in a native HTML form?

IntermediateForms

Answer

By default a custom element is invisible to <form>: it is not in form.elements, its value is not in the FormData, and constraint validation ignores it. Form-associated custom elements fix this. Set static formAssociated = true on the class, call this.attachInternals() once (in the constructor, it throws on a second call), and use the returned ElementInternals. setFormValue(value) publishes the value under the element's name attribute so FormData picks it up. setValidity(flags, message, anchor) participates in constraint validation: pass { valueMissing: true } with a message and an anchor element so the browser can point its native bubble at something real, and pass an empty object to clear.

The platform then calls four lifecycle callbacks on your element: formResetCallback when the form resets, formDisabledCallback when a wrapping fieldset is disabled, formStateRestoreCallback on back-forward navigation and autofill, and formAssociatedCallback when the element is attached to a form. Skipping formResetCallback is the most common omission and it shows up as a Reset button that clears every native input except your component. Two more details worth stating: ElementInternals also carries ARIA defaults such as internals.role and internals.ariaLabel, which set semantics without polluting the host's attributes, and you should add static shadowRootOptions with delegatesFocus: true so clicking the host focuses your inner input and browser validation focuses the right thing.

import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';

@customElement('gs-text-field')
export class GsTextField extends LitElement {
  static formAssociated = true;
  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };

  private internals = this.attachInternals();

  @property() value = '';
  @property({ type: Boolean }) required = false;
  @query('input') private input!: HTMLInputElement;

  private onInput(e: InputEvent) {
    this.value = (e.target as HTMLInputElement).value;
    this.internals.setFormValue(this.value);
    this.validate();
  }

  private validate() {
    if (this.required && !this.value) {
      this.internals.setValidity({ valueMissing: true }, 'This field is required', this.input);
    } else {
      this.internals.setValidity({});
    }
  }

  formResetCallback() {
    this.value = '';
    this.internals.setFormValue('');
    this.internals.setValidity({});
  }

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

Key Points

  • static formAssociated = true plus attachInternals() once in the constructor
  • setFormValue feeds FormData, setValidity feeds constraint validation
  • Implement formResetCallback and formDisabledCallback or forms misbehave
  • delegatesFocus: true so host focus and validation bubbles land correctly
Q26

How do you test a Lit component, and what makes shadow DOM assertions different?

IntermediateTesting

Answer

The standard stack is Web Test Runner (@web/test-runner) with a real browser launcher such as @web/test-runner-playwright, plus @open-wc/testing for helpers. Components run in an actual browser because shadow DOM, adoptedStyleSheets, ElementInternals and custom element upgrade are platform features that jsdom historically emulates incompletely, so a passing jsdom test can hide a real failure. Vitest browser mode is a viable alternative in 2026 for teams already on Vitest, for the same reason: it runs in a real engine.

The core helpers are fixture(html`<gs-badge label='New'></gs-badge>`), which mounts and awaits the first update and cleans up after the test, elementUpdated(el) to await a further cycle, and oneEvent(el, 'gs-select') to await a dispatched event. What differs from testing React is where you look. Assertions go against el.shadowRoot.querySelector(...), never document.querySelector, because the DOM is encapsulated. @open-wc/testing also gives semantic DOM diffing: expect(el).shadowDom.to.equal('<span class="badge">New</span>') normalises whitespace and ignores lit-html's marker comments, which otherwise make string comparison unusable.

Always await an update after setting a property, because Lit batches on a microtask and reading the DOM synchronously after el.label = 'x' reads the old DOM. For accessibility, a11ySnapshot from @open-wc/testing gives you the computed accessibility tree, which is the only reliable way to verify that ElementInternals roles and slotted labels actually reach assistive technology.

import { fixture, html, expect, elementUpdated, oneEvent } from '@open-wc/testing';
import '../src/gs-option.js';
import type { GsOption } from '../src/gs-option.js';

describe('gs-option', () => {
  it('renders the label into the shadow root', async () => {
    const el = await fixture<GsOption>(html`<gs-option label='Backend'></gs-option>`);
    expect(el.shadowRoot!.querySelector('button')!.textContent).to.contain('Backend');
  });

  it('re-renders after a property change', async () => {
    const el = await fixture<GsOption>(html`<gs-option label='A'></gs-option>`);
    el.label = 'B';
    await elementUpdated(el); // required: updates are batched on a microtask
    expect(el).shadowDom.to.equal("<button>B</button>");
  });

  it('dispatches a composed gs-select event', async () => {
    const el = await fixture<GsOption>(html`<gs-option option-id='1'></gs-option>`);
    setTimeout(() => el.shadowRoot!.querySelector('button')!.click());
    const ev = await oneEvent(el, 'gs-select');
    expect(ev.detail.id).to.equal('1');
    expect(ev.composed).to.be.true;
  });
});
๐Ÿ’ก Pro Tip: Run tests in Chromium, Firefox and WebKit in CI. Shadow DOM style and focus behaviour still differ between engines more than most component code does.
Q27

How do you consume Lit components from React, and what does @lit/react add over plain JSX?

IntermediateInteroperability

Answer

React 19 improved custom element support: unknown props that exist as properties on the element are now assigned as properties rather than stringified into attributes, which removes the oldest complaint about React and web components. What React still does not do is wire custom events. There is no onGsSelect prop, so you either attach a ref and call addEventListener yourself in an effect, or you use a wrapper. createComponent from @lit/react generates a typed React component from your element class: it maps a declared events object such as { onGsSelect: 'gs-select' } to real addEventListener calls, sets properties correctly, forwards refs, and gives you full TypeScript types on props derived from the element class.

That typing is the practical value, because hand-written JSX over a custom element gives you no autocomplete and no compile-time checking of property names. For Angular, CUSTOM_ELEMENTS_SCHEMA plus property binding with square brackets works natively and event binding with parentheses works because Angular listens for arbitrary DOM events. Vue 3 handles both properties and events natively once you configure compilerOptions.isCustomElement to stop Vue from treating your tag as an unresolved Vue component.

In all three cases, generate the custom elements manifest and use it to produce wrappers and editor types rather than hand-maintaining them. Server rendering is the remaining gap: a Lit element inside a React Server Component tree renders as an empty tag until client JS upgrades it, unless you also run @lit-labs/ssr.

// wrappers/GsOption.ts
import * as React from 'react';
import { createComponent } from '@lit/react';
import { GsOption as GsOptionElement } from '../src/gs-option.js';

export const GsOption = createComponent({
  react: React,
  tagName: 'gs-option',
  elementClass: GsOptionElement,
  events: {
    onGsSelect: 'gs-select', // typed CustomEvent<GsSelectDetail>
  },
});

// Usage in a React app
// <GsOption
//   label='Backend'
//   optionId='1'
//   onGsSelect={(e) => console.log(e.detail.id)}
// />

// Vue: vite.config.ts
// vue({ template: { compilerOptions: { isCustomElement: (t) => t.startsWith('gs-') } } })

Key Points

  • React 19 sets properties correctly but still does not bind custom events
  • @lit/react createComponent maps events and gives typed props
  • Angular needs CUSTOM_ELEMENTS_SCHEMA; Vue needs isCustomElement
  • Generate wrappers from custom-elements.json rather than by hand
Q28

What is the static-html package for, and why can you not interpolate a tag name normally?

IntermediateTemplates

Answer

lit-html caches a prepared HTML <template> keyed by the identity of the TemplateStringsArray, which is the strings part of the tagged template literal. Because the JavaScript engine reuses the same strings array for the same literal in source, that cache hit is what makes re-renders fast. It also means the static structure of the template is fixed: expressions can only appear where lit-html recorded a part, which is inside text content, attribute values, or element position.

A tag name is structure, not a value, so html`<${this.tag}>hello</${this.tag}>` is invalid and Lit throws. When you genuinely need a dynamic tag, for example a heading component that renders h1 through h6, or a design-system button that becomes an anchor when an href is present, import html and literal from lit/static-html.js. Values wrapped in literal are inlined into the template string before compilation, producing a new cached template per distinct combination. unsafeStatic does the same for arbitrary strings and carries the obvious injection risk, so it must never take user input.

The cost to explain in interviews: every distinct static value creates a new entry in the template cache, so using unsafeStatic with an unbounded set of values is a memory leak that grows for the life of the page. Prefer a small closed set with literal, or just write the branches out explicitly, which is usually clearer for six heading levels anyway.

import { LitElement } from 'lit';
import { html, literal, unsafeStatic } from 'lit/static-html.js';
import { customElement, property } from 'lit/decorators.js';

const TAGS = {
  1: literal`h1`, 2: literal`h2`, 3: literal`h3`,
  4: literal`h4`, 5: literal`h5`, 6: literal`h6`,
} as const;

@customElement('gs-heading')
export class GsHeading extends LitElement {
  @property({ type: Number }) level: 1 | 2 | 3 | 4 | 5 | 6 = 2;

  render() {
    const tag = TAGS[this.level] ?? TAGS[2];
    return html`<${tag} part='heading'><slot></slot></${tag}>`;
  }
}

// unsafeStatic(userInput) is an injection hole AND an unbounded
// template cache. Never do it with values you do not control.
๐Ÿ’ก Pro Tip: If a component's render time grows the longer the page is open, check for unsafeStatic or hand-built html() calls creating a fresh template on every render.
Q29

Write a custom Lit directive. When do you implement update() instead of render()?

AdvancedCustom Directives

Answer

A directive is a class extending Directive (or AsyncDirective) wrapped by the directive() factory, which returns a function you call inside a template. render(...args) receives the binding arguments and returns the value to commit; it is the only method that runs during server rendering, and it must be a pure function of its arguments. update(part, args) runs on the client instead of render being called directly, and it receives the Part object, giving you the underlying DOM node through part.element or part.parentNode plus part.options. Implement update when you need imperative DOM access, when you want to compare against the previously committed value, or when you want to return noChange to skip the commit entirely. The usual pattern is to do the imperative work in update and then delegate to this.render(...args) for the value.

Two more pieces matter for correctness. The constructor receives PartInfo, and you should validate part.type against PartType so a directive meant for attributes throws a clear error rather than misbehaving in a child position. And any directive that subscribes to something outside the template must extend AsyncDirective and implement disconnected() and reconnected(), because otherwise a subtree removed from the DOM keeps its subscription alive; AsyncDirective also gives you this.setValue() to push a value into the part outside a render pass, which is how streaming directives such as asyncReplace work.

import { noChange } from 'lit';
import { directive, AsyncDirective, PartInfo, PartType } from 'lit/async-directive.js';

class RelativeTime extends AsyncDirective {
  private timer?: number;
  private when = 0;

  constructor(partInfo: PartInfo) {
    super(partInfo);
    if (partInfo.type !== PartType.CHILD) {
      throw new Error('relativeTime() can only be used in child position');
    }
  }

  render(when: number) {
    this.when = when;
    return this.format(when);
  }

  update(_part: unknown, [when]: [number]) {
    if (when === this.when && this.timer) return noChange; // skip the commit
    this.timer ??= window.setInterval(() => this.setValue(this.format(this.when)), 30_000);
    return this.render(when);
  }

  private format(when: number) {
    const mins = Math.round((Date.now() - when) / 60_000);
    return mins < 1 ? 'just now' : `${mins} min ago`;
  }

  disconnected() { clearInterval(this.timer); this.timer = undefined; }
  reconnected() { this.timer = window.setInterval(() => this.setValue(this.format(this.when)), 30_000); }
}

export const relativeTime = directive(RelativeTime);

Key Points

  • render() must be pure and is the only path that runs during SSR
  • update(part, args) gives DOM access and can return noChange
  • Validate PartType in the constructor for clear failure messages
  • AsyncDirective plus disconnected()/reconnected() prevents subscription leaks
Q30

What actually leaks memory in a Lit application, and how do you confirm it?

AdvancedMemory

Answer

Lit itself does not leak; the leaks come from things you attached that outlive the element. The five recurring sources are: listeners added to window, document or another long-lived object inside connectedCallback without a matching removeEventListener in disconnectedCallback; observers (ResizeObserver, IntersectionObserver, MutationObserver) created in firstUpdated and never disconnected, which is worse because the observer holds a strong reference to the observed element; timers from setInterval; subscriptions to a store, WebSocket or RxJS observable held in a plain field; and module-level registries such as a Map of id to element instance, which pin every element that was ever created. Note the asymmetry that makes this easy to get wrong: connectedCallback fires again every time the element is moved in the DOM, so a naive add in connect with no remove in disconnect accumulates duplicate listeners on every move, which is both a leak and a correctness bug (handlers fire twice).

Templates have their own version of this in AsyncDirective: a directive that subscribes must implement disconnected(), and Lit signals part disconnection through renderRoot part setConnected, which LitElement drives from its own disconnectedCallback. The fix that scales is not discipline, it is reactive controllers, since hostDisconnected is called for you. To confirm a leak, take a heap snapshot in Chrome DevTools, exercise the flow that mounts and unmounts the component twenty times, force garbage collection, take a second snapshot and compare by constructor name; the Detached elements panel is quicker for DOM-only leaks. If an element shows a retaining path through a listener or an observer, you have found it.

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('gs-sticky-header')
export class GsStickyHeader extends LitElement {
  private observer?: IntersectionObserver;
  private onScroll = () => this.requestUpdate();

  connectedCallback() {
    super.connectedCallback();
    // fires again on every DOM move, so removal must be symmetric
    window.addEventListener('scroll', this.onScroll, { passive: true });
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    window.removeEventListener('scroll', this.onScroll);
    this.observer?.disconnect();
    this.observer = undefined;
  }

  protected firstUpdated() {
    this.observer = new IntersectionObserver(() => this.requestUpdate());
    this.observer.observe(this);
  }

  render() { return html`<slot></slot>`; }
}
๐Ÿ’ก Pro Tip: Any time you write super.connectedCallback(), write the matching disconnectedCallback in the same edit. Better still, move the behaviour into a reactive controller so teardown cannot be forgotten.
Q31

How does server-side rendering work with @lit-labs/ssr and declarative shadow DOM, and what breaks?

AdvancedSSR

Answer

@lit-labs/ssr renders Lit templates to a string or a stream in Node. For each custom element it emits the tag plus a <template shadowrootmode="open"> containing the rendered shadow content and the component's static styles inlined as a <style>. That template element is declarative shadow DOM: the HTML parser attaches it as a real shadow root during parsing, so the markup is styled and readable before any JavaScript executes.

All three major browser engines ship it, which is what made Lit SSR practical. On the client you then hydrate rather than re-render, and hydration requires importing @lit-labs/ssr-client/lit-element-hydrate-support.js before lit itself; that import patches LitElement so its first update adopts the existing DOM by walking lit-html's <!--lit-part--> marker comments instead of clearing and rebuilding. What breaks is anything that assumes a live browser.

There is no window or document on the server, so touching them in the constructor throws; the DOM-dependent half of the lifecycle (firstUpdated, updated, updateComplete, @query, event listeners, observers) does not run at all; and measuring layout is impossible, so any component whose output depends on element size must render a stable fallback server-side. Hydration is also all-or-nothing per top-level template: if the client computes different markup than the server did, Lit reports a hydration value mismatch and falls back to clearing and re-rendering that subtree, which erases the performance benefit and can flash. Non-deterministic values such as Date.now, Math.random or a locale read from the browser are the usual culprits. For Next.js the @lit-labs/nextjs plugin wires the server renderer into the build.

// server.ts
import { render } from '@lit-labs/ssr';
import { RenderResultReadable } from '@lit-labs/ssr/lib/render-result-readable.js';
import { html } from 'lit';
import './components/gs-job-card.js';

export function handler(req, res) {
  const page = html`
    <main><gs-job-card .job=${{ id: '1', title: 'Frontend Engineer' }}></gs-job-card></main>
  `;
  res.setHeader('content-type', 'text/html');
  new RenderResultReadable(render(page)).pipe(res);
}

// client entry: ORDER MATTERS
import '@lit-labs/ssr-client/lit-element-hydrate-support.js';
import './components/gs-job-card.js';

// Emitted markup
// <gs-job-card>
//   <template shadowrootmode="open"><style>...</style><!--lit-part--> ... </template>
// </gs-job-card>

Key Points

  • Output is a <template shadowrootmode> that the parser attaches with no JS
  • Hydration support must be imported before lit, not after
  • No window/document on the server; firstUpdated and updated never run
  • Non-deterministic render values cause a mismatch and a full re-render
Q32

A @property() field is undefined at runtime and never triggers a render. What is wrong with the TypeScript config?

AdvancedDecorators

Answer

This is the useDefineForClassFields trap and it is the single most common broken-setup question in Lit interviews. With experimentalDecorators: true, Lit's @property decorator installs a getter/setter pair on the class prototype. Class fields, under the ES2022 semantics that useDefineForClassFields: true selects, are emitted with Object.defineProperty on the instance rather than a simple assignment.

An own data property on the instance shadows the prototype accessor completely, so the setter never runs, no update is scheduled, and reading the property returns the raw field value with no reactivity. TypeScript defaults useDefineForClassFields to true whenever target is ES2022 or higher, which every modern config uses, so the failure appears the moment someone bumps the target. The fix for experimental decorators is explicit: set useDefineForClassFields to false.

The alternative, and the direction the ecosystem is moving, is standard TC39 decorators: set experimentalDecorators to false, keep useDefineForClassFields at its default true, and declare reactive properties with the accessor keyword, @property() accessor label = ''. Lit 3 supports both modes, but you cannot mix them in one compilation unit. Two related symptoms to recognise: with standard decorators and no accessor keyword you get a compile error rather than silent breakage, which is a real argument for migrating; and if you compile with Babel instead of tsc, the corresponding knob is setPublicClassFields in @babel/plugin-proposal-class-properties, or the version-2023-05 decorators option in the newer plugin.

// Option A: experimental (legacy) decorators
// tsconfig.json
// {
//   "compilerOptions": {
//     "target": "ES2022",
//     "experimentalDecorators": true,
//     "useDefineForClassFields": false   // REQUIRED, or properties go dead
//   }
// }
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('gs-legacy')
export class GsLegacy extends LitElement {
  @property() label = 'hi';
  render() { return html`${this.label}`; }
}

// Option B: standard TC39 decorators
// { "experimentalDecorators": false, "useDefineForClassFields": true }
@customElement('gs-standard')
export class GsStandard extends LitElement {
  @property() accessor label = 'hi'; // note the accessor keyword
  render() { return html`${this.label}`; }
}
๐Ÿ’ก Pro Tip: Quick diagnosis in a browser: run Object.getOwnPropertyDescriptor(el, 'label'). If it returns a value descriptor instead of undefined, the class field is shadowing Lit's accessor and your config is wrong.
Q33

A Lit table renders ten thousand rows and interaction is janky. How do you diagnose and fix it?

AdvancedPerformance

Answer

Start by separating the three costs: creating DOM, committing bindings, and style recalculation. Profile in Chrome DevTools with a Performance recording of one interaction, then look at whether time sits in scripting (template creation and binding commits) or in rendering and painting (style recalc and layout). The structural fix that dominates everything else is not rendering ten thousand rows: use @lit-labs/virtualizer, either the <lit-virtualizer> element or the virtualize() directive, so only the visible window plus a buffer exists in the DOM.

Nothing you do to lit-html beats deleting 9,950 rows. After that, the Lit-specific levers are these. Confirm each row is one cached template: if your row builder branches into differently shaped html literals, you are creating multiple templates, and if anything calls html with a dynamically constructed strings array, template caching is defeated entirely and every render reparses.

Choose map over repeat unless rows hold DOM state, since repeat adds key bookkeeping and node moves. Push per-row work out of render: precompute formatted strings in willUpdate rather than calling Intl.NumberFormat inside the loop. Use one component per row only if the row is genuinely complex, because every custom element carries a shadow root, a stylesheet adoption and its own update scheduler, and ten thousand of those is a real cost even with shared stylesheets.

Add shouldUpdate to reject updates from properties the table does not render. And check that reflected properties are not writing attributes per row on every scroll, which invalidates style recalculation across the whole table.

import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@lit-labs/virtualizer';

type Row = { id: string; name: string; ctc: number };
const inr = new Intl.NumberFormat('en-IN', { maximumFractionDigits: 1 });

@customElement('gs-candidate-table')
export class GsCandidateTable extends LitElement {
  @property({ attribute: false }) rows: Row[] = [];
  @state() private formatted: Array<Row & { ctcLabel: string }> = [];

  protected willUpdate(changed) {
    // format once per data change, not once per row per render
    if (changed.has('rows')) {
      this.formatted = this.rows.map((r) => ({ ...r, ctcLabel: `${inr.format(r.ctc)} LPA` }));
    }
  }

  render() {
    return html`
      <lit-virtualizer
        .items=${this.formatted}
        .renderItem=${(r) => html`<div class='row'>${r.name} <span>${r.ctcLabel}</span></div>`}
      ></lit-virtualizer>
    `;
  }
}

Key Points

  • Virtualize first: @lit-labs/virtualizer beats every micro-optimisation
  • One stable html literal per row keeps the template cache effective
  • map over repeat unless rows carry DOM state that must follow reorders
  • Hoist Intl and date formatting into willUpdate, out of the render loop
  • Reflected properties mutating per row invalidate style recalc table-wide
Q34

Two independently deployed micro-frontends both bundle a component named gs-button. How do you keep the page working?

AdvancedProduction Architecture

Answer

The CustomElementRegistry is global and single-shot, so the second bundle's define call throws NotSupportedError and, depending on where it sits in the module graph, can take down the whole bundle's initialisation. Worse, if the two copies are different versions, whichever one wins defines behaviour for every gs-button on the page, so the other team's component silently renders with the wrong API. There are four responses, in order of preference.

One, deduplicate at the platform level: publish the design system as a singleton, load it once via an import map or Module Federation shared singleton, and forbid bundling it. This is the only option that also solves the multiple-versions-of-Lit problem, which you can spot from the dev-mode warning about multiple versions loaded and which breaks instanceof checks and cross-copy directives. Two, version the tag name at build time so each release registers gs-button-v3; ugly but deterministic, and used by real design systems that cannot control their consumers.

Three, guard every define with an existence check, which prevents the crash but leaves you with whichever copy loaded first, so treat it as damage limitation rather than a fix. Four, scoped custom element registries, which let a shadow root carry its own registry so two versions can coexist under different roots. The scoped registry API is landing in Chromium and there is a polyfill plus @lit-labs/scoped-registry-mixin, so it is viable but still needs the polyfill for full coverage. In interviews, naming deduplication first and treating the rest as mitigations is the answer that signals production experience.

// 1. Damage limitation: never let a duplicate define crash the bundle
export function safeDefine(tag: string, ctor: CustomElementConstructor) {
  const existing = customElements.get(tag);
  if (existing && existing !== ctor) {
    console.warn(`[design-system] ${tag} already defined by another bundle`);
    return;
  }
  if (!existing) customElements.define(tag, ctor);
}

// 2. Real fix: one shared copy via an import map
// <script type='importmap'>
// { "imports": {
//     "lit": "/vendor/lit@3/index.js",
//     "@acme/design-system": "/vendor/ds@3.4.0/index.js"
// } }
// </script>

// 3. Coexistence: scoped registry per shadow root
// import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
// class Panel extends ScopedRegistryHost(LitElement) {
//   static elementDefinitions = { 'gs-button': GsButtonV3 };
// }
๐Ÿ’ก Pro Tip: Add a build-time check that fails CI if lit appears more than once in the dependency tree. Catching it at install time is far cheaper than debugging why instanceof LitElement returns false in production.
Q35

How do signals change Lit's update model, and when are they worth adopting?

AdvancedReactivity

Answer

Lit's default model is component-granular: any reactive property change re-runs the whole render() and lit-html commits only the bindings whose values differ. That is already efficient because the diff is per binding, not per node, but the render function still runs in full and every binding's value is recomputed. Signals change the granularity of what triggers work. @lit-labs/signals builds on the TC39 signals proposal polyfill and gives you two tools.

The SignalWatcher mixin makes a component track every signal read during render and request an update when any of them changes, which removes the plumbing of copying store state into @state properties and keeping it synchronised. The watch() directive goes further: bind watch(mySignal) into a single expression and only that DOM part is updated when the signal changes, with no render() call at all. That is genuinely fine-grained reactivity inside an otherwise coarse-grained component model, and it is the right tool for a value that ticks frequently, a clock, a live price, a progress percentage, inside a component whose surrounding template is expensive.

When is it worth it? When application state is shared across many components and the alternative is context plus manual subscription, or when profiling shows render() itself is hot. When is it not?

For ordinary component-local state, @state is simpler, fully typed and has no extra dependency. Be honest in interviews that @lit-labs/signals is still a labs package and the underlying proposal is not yet a shipped browser API, so treat it as a considered bet rather than a default.

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { SignalWatcher, signal, computed, watch } from '@lit-labs/signals';

// Shared application state, outside any component
export const applications = signal<Array<{ id: string; stage: string }>>([]);
export const shortlisted = computed(
  () => applications.get().filter((a) => a.stage === 'shortlist').length,
);
export const elapsed = signal(0);
setInterval(() => elapsed.set(elapsed.get() + 1), 1000);

@customElement('gs-pipeline')
export class GsPipeline extends SignalWatcher(LitElement) {
  render() {
    return html`
      <!-- read during render: whole component re-renders on change -->
      <h2>${shortlisted.get()} shortlisted</h2>

      <!-- watch(): only this text node updates, render() is not re-run -->
      <small>live for ${watch(elapsed)}s</small>
    `;
  }
}

Key Points

  • SignalWatcher auto-tracks signals read in render and requests updates
  • watch() pins an update to one binding without re-running render()
  • Best fit: shared cross-component state and high-frequency values
  • @state stays the right default for ordinary component-local state
  • @lit-labs/signals is still a labs package; adopt it deliberately

Companies Hiring Lit

Google
Adobe
IBM
SAP Labs India
Red Hat
Cisco
VMware
Publicis Sapient

Salary Insights

Average in India
โ‚น6-18 LPA

Frequently Asked Questions

How much does a Lit developer earn in India?

The band is roughly โ‚น6-18 LPA in 2026. Almost nobody is hired as a 'Lit developer' though; the title is frontend engineer, UI platform engineer or design systems engineer, and Lit is one line in the requirements. That matters for your negotiation, because the pay is set by the seniority of the role, not the library. The upper end and beyond sits with product companies that run a real design system: Google, Adobe, IBM, SAP Labs India, Red Hat and Cisco all have India teams shipping web components. Engineers who can also do accessibility to WCAG level, server rendering, and framework interop wrappers command a premium, because that combination is scarce and design-system teams are small.

How long does it take to become interview-ready in Lit if I already know React?

Two to four weeks of evening work is realistic. The templating and property model take about three days, because tagged template literals and reactive properties map cleanly onto JSX and state. The remaining time goes into the platform, which is the part React never taught you: shadow DOM style encapsulation, slots and ::slotted, event composition and retargeting, ElementInternals for forms and ARIA, and declarative shadow DOM. Build two real components, a form field that participates in a native form and a virtualized list, and you will have hit most of the questions in this set. Reading the source of an open design system such as Material Web or Spectrum Web Components is the fastest way to see production patterns.

What do interviewers expect from a fresher versus someone with five years of experience?

From a fresher: register a custom element correctly, know the four binding syntaxes and why the dot matters, explain @property versus @state, name the lifecycle hooks in order, and write a component with slots and static styles in a live round. From an experienced candidate the questions move to judgement. Why did you choose Lit over React for that system. How do you version a component library that ships to teams you do not control. What happened the first time you tried server rendering. How do you handle the fact that two versions of your design system will end up on the same page. Senior rounds also expect an accessibility answer, because shadow DOM makes cross-root ARIA genuinely hard, and interviewers use it to separate people who shipped from people who prototyped.

Is Lit worth learning in 2026 when most Indian job postings ask for React?

As a first skill, no. React and Next.js open far more doors in the Indian market and you should be strong there first. As a second skill it is unusually high leverage, because the supply of engineers who genuinely understand web components is thin while demand is concentrated in exactly the teams that pay well: platform and design-system groups inside large product organisations. It also ages well. Custom elements are a browser standard, so the knowledge survives framework cycles in a way that a specific React state library does not. The pragmatic framing for an interview is that you use React for applications and Lit for the shared component layer beneath them.

Lit versus Stencil versus writing vanilla web components: how do I justify the choice?

Vanilla custom elements are fine for one or two simple elements and become painful the moment you need efficient re-rendering, because you end up hand-writing the diffing that lit-html gives you in five kilobytes. Stencil is a compiler with JSX that generates web components and can emit framework wrappers and lazy-loading bundles automatically, which suits teams that want a build-heavy, batteries-included pipeline. Lit is a runtime library with no required compiler, so what you write is what ships and debugging is direct. The honest trade-off to state in an interview: Stencil does more for you at the cost of a build step you cannot remove, Lit keeps you closer to the platform and expects you to assemble tooling such as the custom elements manifest and framework wrappers yourself.

Which React skills transfer to Lit, and which habits do I have to unlearn?

Component decomposition, unidirectional data flow, immutable state updates and derived-state discipline all transfer directly, and the immutability habit is essential because Lit's dirty check is reference-based. What you unlearn: there is no reconciler re-running your whole tree, so a parent update does not automatically re-render a child unless a bound property changed. There is no synthetic event system, so composed and bubbles are your responsibility and event.target is retargeted at shadow boundaries. There are no hooks; reactive controllers fill that role and they are classes with lifecycle hooks, not closures. And styling is encapsulated by default, so a global stylesheet will not reach inside your component, which surprises every React engineer exactly once.

Introduction

Lit is Google's small library for building standards-based custom elements. It is deliberately not a framework: there is no router, no store, no compiler step you are forced to adopt. What you get is LitElement (a base class over HTMLElement), a reactive property system, scoped styles backed by constructable stylesheets, and lit-html, a templating engine that uses tagged template literals and the browser's own HTML parser instead of a virtual DOM. The whole runtime lands at roughly five kilobytes minified and compressed. That combination is why design-system teams keep choosing it: Material Web, Adobe Spectrum Web Components, IBM Carbon and the Red Hat design system are all built on Lit, and the components they ship keep working when the consuming app switches from Angular to React.

Lit interviews look different from React interviews because the hard parts sit in the platform, not the library. Panels probe the reactive property system and the notEqual dirty check, attribute versus property bindings, shadow DOM style encapsulation and how theming crosses that boundary, lifecycle ordering across willUpdate, update, firstUpdated and updated, the updateComplete promise, directives, reactive controllers, and packages such as @lit/task, @lit/context and @lit-labs/ssr. In India these roles cluster in platform and design-system teams at Google, Adobe, IBM, SAP Labs India, Red Hat and Cisco, plus product engineering firms building white-label component libraries for enterprise clients. The band sits around โ‚น6-18 LPA depending on depth.

This set covers 35 Lit interview questions asked in 2026, ordered basic first and then intermediate and advanced. Most carry a runnable TypeScript or JavaScript example, because Lit interviews almost always include a live coding round where you build a component from an empty file. The basic block locks down bindings, styles, slots and lifecycle. The intermediate block covers the areas where candidates lose offers: list keying, controllers, async state, form participation and testing. The advanced block goes into custom directives, memory leaks, server rendering with declarative shadow DOM, the decorator configuration trap, and signals.

Ready to practice Lit interviews?

Don't just read, practice these Lit questions live with an AI interviewer that asks follow-ups and scores your answers.

โœ“AI-powered practice
โœ“Instant feedback
โœ“Free to start
Start Free Mock Interview