Lesson 01-01, when presenting Lit against the big application frameworks, left a promise pending: "this comparison will be revisited in much more detail —including concrete interoperability examples— in the integration and interoperability module." That moment has arrived. The previous lesson showed that a Lit component works in any plain HTML, with no framework at all; this lesson goes one step further and examines what happens when that same component is used inside an application built with React, Vue, or Angular, three environments that, each in its own way, were not designed with Custom Elements in mind from the start, and that therefore present concrete friction points —different in each case— when integrating them.

Contents

  1. Why interoperability isn't automatic despite the standard
  2. React: the historical problem of non-string properties
  3. React: custom events and the useRef solution
  4. <task-card> inside a sample React component
  5. Vue: near-direct interoperability
  6. Angular: CUSTOM_ELEMENTS_SCHEMA and the remaining nuances
  7. Summary table of friction points by framework

  1. Why interoperability isn't automatic despite the standard

The previous lesson insisted that a Lit component is, ultimately, a standard Custom Element, usable in any HTML without depending on any framework. That statement remains true inside a React, Vue, or Angular application: in all three cases, it is perfectly possible to write a Lit component's tag (<task-card>) directly in that application's markup, and the browser will recognize and render it without any problem. The friction doesn't appear in whether the element renders, but in how each framework decides to communicate with it: each of the three builds, on top of the real DOM, its own model of how data gets passed to an element and how events are received from it, and that model doesn't always match the way Custom Elements —and therefore Lit— expect to receive data and emit events.

Specifically, there are two recurring friction points, to different degrees depending on the framework: first, how to pass a value that isn't a simple text string (the same underlying problem already seen in the previous lesson, but now from the side of the framework that generates the markup, not from hand-written HTML); second, how to listen for a custom event (CustomEvent, seen in lesson 05-02) dispatched by a Lit component, when the framework's own event system expects a different format.

  1. React: the historical problem of non-string properties

React, until well into its history (and only fully from its most recent versions, with support for Custom Element properties still settling across different versions), has had a well-known problem when using Custom Elements inside JSX: JSX, by default, translates any attribute written on a lowercase tag (such as task-list, which React treats as a native DOM element, not as a React component of its own) directly into an HTML attribute, not into a JavaScript property. This is exactly the same problem described in the previous lesson —the difference between attribute and property—, but now hidden behind a syntax that, at first glance, looks more flexible than it really is:

// This does NOT set the `tareas` property as a real array
function Tablero() {
  const tareas = [{ id: 't1', titulo: 'Diseñar', estado: 'pendiente', prioridad: 2 }];
  return <task-list tareas={tareas}></task-list>;
}

Although tareas={tareas} looks, by its syntax, like a property assignment similar to the ones Lit uses internally with the dot prefix (.tareas="${...}"), React translates it, for an element it doesn't recognize as its own component, into an attempt to set an HTML attribute; and since an HTML attribute can only be a string, React ends up internally calling something equivalent to elemento.setAttribute('tareas', tareas.toString()), which for an array produces the literal string "[object Object]" or an equally useless representation, not the actual array <task-list> needs in its reactive tareas property.

  1. React: custom events and the useRef solution

The second historical problem is symmetrical, but for events: React recognizes, inside JSX, attributes with the on prefix (onClick, onChange) and translates them into its own internal event-handling system (React's SyntheticEvent), but that recognition is limited to event names React already knows in advance for native DOM elements. A CustomEvent with an application-specific name, such as tarea-cambiada (dispatched by <task-card> since lesson 05-02), has no equivalent on attribute recognized by React, and writing <task-card onTareaCambiada={...}> hooks up nothing, because React doesn't know how to translate that name into a real addEventListener('tarea-cambiada', ...) on the element.

The established solution for both problems —complex properties and custom events— is the same, and it matches exactly the technique already seen in the previous lesson for plain HTML: get a direct reference to the DOM element and manipulate it with native APIs, instead of relying on JSX's automatic translation. In React, that reference is obtained with the useRef hook, and property assignment plus event listener registration are done inside a useEffect:

import { useEffect, useRef } from 'react';

function Tablero({ tareas }) {
  const refLista = useRef(null);

  useEffect(() => {
    const elemento = refLista.current;
    elemento.tareas = tareas;

    const manejarCambio = (evento) => {
      console.log('Tarea cambiada:', evento.detail);
    };
    elemento.addEventListener('tarea-cambiada', manejarCambio);

    return () => elemento.removeEventListener('tarea-cambiada', manejarCambio);
  }, [tareas]);

  return <task-list ref={refLista}></task-list>;
}

useEffect runs after React has mounted the real element in the DOM, at which point refLista.current already points to the concrete <task-list> instance; from there, elemento.tareas = tareas is an ordinary JavaScript property assignment, identical to the one in section 5 of the previous lesson, and elemento.addEventListener('tarea-cambiada', ...) is the same native DOM mechanism used since lesson 05-02, with no intermediate React translation involved. The function returned by useEffect removes the listener when the component unmounts or when tareas changes and the effect runs again, avoiding duplicate listeners accumulating across successive re-renders.

It's worth noting that the most recent versions of React have been progressively reducing this friction, in some cases automatically detecting whether a JSX property corresponds to a real property of the element rather than an attribute; but, since this isn't a guaranteed or uniform behavior across versions, the useRef plus useEffect pattern remains the robust, explicit technique that doesn't depend on which specific React version is in use.

  1. <task-card> inside a sample React component

With the two previous pieces, here is how a React component that wraps <task-card> to expose it with a more convenient API to the rest of the React application using it would look:

// src/components/TarjetaTarea.jsx
import { useEffect, useRef } from 'react';
import './task-card.js'; // registers <task-card> via customElements.define

function TarjetaTarea({ tarea, onCambio }) {
  const refTarjeta = useRef(null);

  useEffect(() => {
    const elemento = refTarjeta.current;
    elemento.titulo = tarea.titulo;
    elemento.estado = tarea.estado;
    elemento.prioridad = tarea.prioridad;

    const manejarCambio = (evento) => onCambio(evento.detail);
    elemento.addEventListener('tarea-cambiada', manejarCambio);
    return () => elemento.removeEventListener('tarea-cambiada', manejarCambio);
  }, [tarea, onCambio]);

  return <task-card ref={refTarjeta}></task-card>;
}

export default TarjetaTarea;

TarjetaTarea is a perfectly ordinary React component from the rest of the application's point of view —it receives props (tarea, onCambio) with React's usual syntax, and translates them internally into <task-card>'s real properties and events—, acting as an adaptation layer between React's prop model and Lit's property and CustomEvent model. Note that titulo, estado, and prioridad could, in this specific case, be passed equally well as plain JSX attributes (titulo={tarea.titulo}), because they are simple strings or numbers and don't suffer from the problem in section 2; they are assigned here as properties inside the same useEffect only for uniformity with the rest of the pattern, not because it's strictly necessary for these three particular fields.

  1. Vue: near-direct interoperability

Vue resolves most of this friction natively, with no need for any pattern equivalent to useRef plus useEffect for common cases. The template of a single-file .vue component recognizes binding syntax (:propiedad="valor") directly on any tag, including a Custom Element tag, and translates it into a real DOM property assignment when the value isn't a simple string, exactly the behavior React needs a manual pattern to achieve:

<template>
  <task-list :tareas="tareas" @tarea-cambiada="manejarCambio"></task-list>
</template>

<script setup>
import { ref } from 'vue';
import './task-list.js';

const tareas = ref([
  { id: 't1', titulo: 'Diseñar la base de datos', estado: 'hecha', prioridad: 2 },
]);

function manejarCambio(evento) {
  console.log('Tarea cambiada:', evento.detail);
}
</script>

:tareas="tareas" sets the real tareas property on the element (not a text attribute), because Vue, unlike React with JSX, decides at runtime whether the value should be applied as a property or an attribute based on the value's type and on whether the name matches a known property of the DOM element; and @tarea-cambiada="manejarCambio" registers a native event listener (addEventListener('tarea-cambiada', ...)) without needing tarea-cambiada to be an event name Vue already knows in advance, unlike the limitation pointed out for React in section 3. The only adjustment sometimes needed is telling Vue explicitly which tags are Custom Elements and not misspelled Vue components of its own, via the compilerOptions.isCustomElement option in the project configuration (normally in vite.config.js), so Vue doesn't emit an "unknown component" warning when it encounters <task-list> in the template.

  1. Angular: CUSTOM_ELEMENTS_SCHEMA and the remaining nuances

Angular occupies an intermediate position: its template system is stricter than Vue's when accepting tags and attributes it doesn't already recognize, precisely because Angular validates at compile time which elements and properties exist, so it can offer type checking and catch errors before running the application. By default, Angular refuses to compile a template containing an unknown tag such as <task-card>, with an error like "'task-card' is not a known element." The solution is to explicitly declare, in the relevant module or component, that the use of Custom Elements not recognized by Angular itself is allowed:

// tablero.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import './task-card.js';

@Component({
  selector: 'app-tablero',
  template: `
    <task-card
      [titulo]="tarea.titulo"
      [estado]="tarea.estado"
      (tarea-cambiada)="manejarCambio($event)"
    ></task-card>
  `,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class TableroComponent {
  tarea = { titulo: 'Revisar propuesta', estado: 'progreso' };

  manejarCambio(evento: CustomEvent) {
    console.log('Tarea cambiada:', evento.detail);
  }
}

schemas: [CUSTOM_ELEMENTS_SCHEMA] disables, for that particular component, the strict checking of unknown tags and properties, allowing Angular to compile the template without complaining about <task-card> or its attributes. Once that schema is added, Angular's property binding syntax ([titulo]="...", with brackets) does correctly translate into a DOM property assignment, similarly to Vue; and the event syntax ((tarea-cambiada)="...", with parentheses) does correctly listen for a CustomEvent with that exact name, without the predefined-name limitation that affects React. The cost of this approach is, precisely, losing the type checking Angular offers for its own components: with CUSTOM_ELEMENTS_SCHEMA active, Angular can no longer warn at compile time if [titulol] is written by mistake instead of [titulo], and that error would only surface at runtime, with the titulol property simply ignored by <task-card>.

  1. Summary table of friction points by framework

Aspect React Vue Angular
Passing a complex property (array, object) Requires useRef + useEffect; JSX would treat it as a text attribute Direct with :propiedad="valor"; Vue decides property or attribute automatically Direct with [propiedad]="valor", once CUSTOM_ELEMENTS_SCHEMA is declared
Listening for a CustomEvent with a custom name Requires manual addEventListener inside useEffect; no recognized on attribute Direct with @nombre-evento="manejador" Direct with (nombre-evento)="manejador($event)", once the schema is declared
Prior configuration needed None special, but requires the manual pattern on every use compilerOptions.isCustomElement to avoid warnings schemas: [CUSTOM_ELEMENTS_SCHEMA] in every component using Custom Elements
Cost of the solution Repetitive code (one adaptation layer per wrapped component) Practically none for common cases Loss of strict type checking in that template
Trend Improving in recent versions, but with no uniform guarantee Already resolved natively for a while Stable, requires the explicit schema step

The underlying pattern, visible across all three columns, is always the same: the more a framework decides to inspect and validate in advance the tags and attributes of a template (Angular, and to a lesser extent React with JSX), the more friction appears when introducing an element that framework cannot know about in advance because it isn't part of its own component ecosystem; the more a framework decides to delegate at runtime the decision of whether something is a property or an attribute (Vue, quite directly), the less friction there is. None of the three frameworks prevents, in any case, using a Lit component; the difference lies in how much additional adaptation code needs to be written to do it comfortably.

Common Mistakes and Tips

  • Passing an array or an object via a JSX attribute in React expecting it to work as a property: as explained in section 2, JSX by default translates to an HTML attribute on a tag React doesn't recognize as its own component; the useRef plus useEffect pattern from section 3 is needed for any value that isn't a simple string or number.
  • Forgetting to declare CUSTOM_ELEMENTS_SCHEMA in Angular: without it, the application won't even compile, with an "unknown element" error pointing directly at the Lit component's tag; it's a mandatory, not optional, step in any Angular component using third-party or custom Custom Elements.
  • Assuming Vue needs the same manual pattern as React: as seen in section 5, Vue automatically resolves most cases with its usual binding syntax; replicating the useRef pattern in Vue isn't necessary and only adds redundant code.
  • Not removing event listeners added manually with addEventListener: in React's pattern from section 3, forgetting the cleanup function returned by useEffect accumulates duplicate listeners on every re-render that re-runs the effect, a memory-management bug that's easy to introduce and hard to spot without profiling tools.

Exercises

  1. Rewrite the TarjetaTarea React component from section 4 using the official @lit/react library, which offers a createComponent(...) function capable of automatically generating a wrapping React component from a Lit component's class, without needing to hand-write the useRef and useEffect. (Hint: look up createComponent's signature in the @lit/react documentation; you don't need to memorize it, just reason about what information it would need to receive to be able to generate the wrapper automatically: the element's class, its tag name, and which events it should translate into React props.)
  2. Explain, based on section 6, why CUSTOM_ELEMENTS_SCHEMA reduces Angular's type safety, and specifically which check the Angular compiler stops performing on a template that declares it.
  3. A teammate, working in Vue, is surprised that :tareas="tareas" works directly with no extra step, while in React they needed the full useRef pattern. Explain the underlying difference between the two frameworks that causes that different experience, drawing on sections 2 and 5.

Solutions

  1. createComponent from @lit/react needs, at minimum, three pieces of information to generate the wrapper automatically: the React library (react, so it can use its hooks internally without the project having to declare them), the Lit component's own class (TaskCard, so it can inspect its reactive properties declared in static properties), and the already-registered tag name ('task-card'). With those three pieces, createComponent can automatically generate, internally, exactly the same useRef and useEffect pattern hand-written in section 4 —assigning each known reactive property as a real JavaScript property, not as an attribute— and, for events, accept an additional map (something like events: { onTareaCambiada: 'tarea-cambiada' }) that translates each CustomEvent into a function-typed prop following React's usual on... naming convention, without the developer having to hand-write addEventListener in every wrapped component.
  2. CUSTOM_ELEMENTS_SCHEMA disables the validation Angular performs, at compile time, on whether a tag exists as a component or directive known to Angular, and on whether each [propiedad] or (evento) written in a template actually corresponds to a declared entry of that tag (an @Input() or an @Output(), in Angular's terminology). Without the schema, Angular can warn at compile time about a typo such as [titulol] instead of [titulo]; with the schema active, that same check is disabled for the component's entire template, and an error of that kind would only surface at runtime, with the misspelled property simply ignored with no warning.
  3. The underlying difference is the moment and the criterion each framework uses to decide whether a value should be applied as a JavaScript property or as an HTML attribute. React with JSX applies a fixed, syntactic rule: on a tag it doesn't recognize as its own component, any JSX attribute translates by default into an HTML attribute, without inspecting the value's type or the real element. Vue, on the other hand, decides at runtime, inspecting the DOM element itself: if a property with that name exists on the element (something it can check dynamically, since tareas is a real reactive property declared by Lit in static properties and exposed as an instance property of the Custom Element), Vue assigns it as a property; if not, it falls back to an attribute. That dynamic inspection is precisely what lets Vue correctly resolve cases like :tareas="tareas" with no additional manual pattern.

Conclusion

This lesson has fulfilled the promise from lesson 01-01: a practical comparison, with concrete examples, of how a Lit component integrates inside React, Vue, and Angular, showing that the underlying problem is always the same —how to pass non-string values and how to listen for custom events— but that each framework solves it, or forces it to be solved manually, differently depending on how much it validates its own templates in advance. With interoperability toward other frameworks now covered, one integration scenario of a different nature remains: what happens when a Lit component isn't rendered in the end user's browser, but on a server, before the page even has a chance to load. The next lesson presents @lit-labs/ssr and the problem of server-side rendering for Web Components.

Lit Course

Module 1: Introduction to Lit and Web Components

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved