jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Lit toàn tập — Reactive Properties, Bindings, Directives & Events

Hướng dẫn dùng Lit cụ thể: LitElement, reactive properties, 5 kiểu binding (text/attribute/property/boolean/event), built-in directives, custom directive, vòng đời update, styling và Reactive Controllers.

Web Components are powerful but verbose {Web Components mạnh nhưng dài dòng}: manual attachShadow, manual innerHTML, manual re-rendering on every state change {tự attachShadow, tự innerHTML, tự re-render mỗi lần state đổi}. Lit is a ~5KB library that keeps the standards-based foundation but adds declarative templates and reactive rendering on top {Lit là thư viện ~5KB giữ nền tảng dựa trên chuẩn nhưng thêm template khai báorender phản ứng lên trên}.

If you haven’t read the Web Components deep dive, do that first — Lit is just a thin, ergonomic layer over those exact APIs {Nếu bạn chưa đọc bài Web Components, hãy đọc trước — Lit chỉ là một lớp mỏng, tiện tay phủ lên đúng các API đó}.

Vì sao Lit {Why Lit}

 Vanilla Web Component                Lit
 ─────────────────────                ───
 attachShadow thủ công        →       tự động (shadow root)
 innerHTML + querySelector    →       html`` template + binding
 tự gọi render mỗi lần đổi    →       reactive: đổi property → re-render
 getAttribute/setAttribute    →       @property tự map attribute ↔ property
 tự diff DOM                  →       lit-html diff hiệu quả (chỉ đổi phần đổi)

Lit’s core ideas {Ý tưởng cốt lõi của Lit}: state lives in reactive properties, the UI is a pure function of state via the html tagged template, and Lit re-renders only the parts that changed {state nằm trong reactive properties, UI là hàm thuần của state qua tagged template html, và Lit chỉ re-render đúng phần thay đổi}.


1. Cài đặt & component đầu tiên {Setup & first component}

npm install lit
// hello-lit.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('hello-lit') // = customElements.define('hello-lit', HelloLit)
export class HelloLit extends LitElement {
  // Scoped styles — parse 1 lần, share qua mọi instance (Constructable Stylesheet).
  static styles = css`
    p { color: var(--accent, rebeccapurple); font: 600 14px system-ui; }
  `;

  // Reactive property: đổi giá trị → tự động re-render.
  @property() name = 'thế giới';

  // render() trả về template. Chạy lại mỗi khi reactive state đổi.
  render() {
    return html`<p>Xin chào, ${this.name}!</p>`;
  }
}
<hello-lit name="vinxi"></hello-lit>
<script type="module" src="/hello-lit.js"></script>

Decorators (@customElement, @property) cần bật trong tsconfig.json ("experimentalDecorators": true cho TS cũ, hoặc standard decorators với TS 5+ — khi đó nhớ dùng từ khoá accessor, xem mục 2.2). Không dùng TS? Có API tương đương bằng static properties (xem mục 2). {Decorators cần cấu hình TS; với standard decorators nhớ dùng accessor; nếu không dùng TS có API static properties tương đương.}


2. Reactive properties — trái tim của reactivity {Reactive properties — the heart of reactivity}

A reactive property triggers a re-render when it changes {Một reactive property kích hoạt re-render khi nó đổi}. Declare it two ways {Khai báo hai cách}:

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

class MyEl extends LitElement {
  @property({ type: String }) label = '';      // public API, map ↔ attribute
  @property({ type: Number }) count = 0;
  @property({ type: Boolean }) open = false;
  @state() private _internal = 0;               // state nội bộ, KHÔNG là attribute
}

Không dùng decorator (JS thuần) {Without decorators (plain JS)}:

class MyEl extends LitElement {
  static properties = {
    label: { type: String },
    count: { type: Number },
    open:  { type: Boolean },
  };
  constructor() { super(); this.label = ''; this.count = 0; this.open = false; }
}

2.1. Các option của @property {@property options}

OptionÝ nghĩa
typeBộ chuyển attribute (string) ↔ property: String, Number, Boolean, Array, Object
attributeTên attribute (false = không map; 'my-attr' = đổi tên)
reflecttrue = property đổi thì ghi ngược ra attribute (cho CSS/DOM thấy)
useDefaulttrue = không reflect giá trị mặc định ra attribute lúc đầu; xoá attribute thì property quay về default (giống id của native element). Dùng kèm reflect
converterBộ chuyển tùy chỉnh { fromAttribute, toAttribute }
hasChangedHàm quyết định “có thực sự đổi không” → bỏ qua re-render thừa
noAccessorKhông tạo getter/setter (hiếm dùng)
@property({
  type: Number,
  reflect: true,        // count đổi → <my-el count="3"> để style :host([count]) được
  useDefault: true,     // không phun count="0" lúc mount; xoá attr → count về 0
  attribute: 'item-count',
  hasChanged: (next, prev) => next !== prev, // mặc định là so sánh !==
})
accessor count = 0;

Best practices khi reflect {Best practices when reflecting} (theo docs): reflect dè dặt — attribute nên là input từ ngoài vào, không phải state element tự quản {reflect dè dặt — attribute nên là input từ ngoài}; không reflect Object/Array (serialize JSON nặng, tốn bộ nhớ) {không reflect Object/Array vì serialize nặng}; khi đã reflect thì thường nên kèm useDefault: true để element không tự sinh attribute mà user không set {khi đã reflect thì thường kèm useDefault: true}.

@state vs @property: use @property for public API consumers set from HTML/JS {dùng @property cho API công khai mà người dùng set từ HTML/JS}; use @state for internal state that should still trigger renders but isn’t an attribute {dùng @state cho state nội bộ vẫn cần trigger render nhưng không phải attribute}.

Reactivity là per-binding, không deep {Reactivity là theo từng binding, không sâu}: mutating an object/array in place (this.items.push(x)) won’t trigger an update because the reference didn’t change {thay đổi tại chỗ một object/array sẽ không trigger update vì tham chiếu không đổi}. Assign a new reference {Hãy gán tham chiếu mới}: this.items = [...this.items, x]. Need to mutate in place? Call this.requestUpdate() manually {Bắt buộc mutate tại chỗ? Gọi this.requestUpdate() thủ công} — nhưng chỉ component đó re-render, con nhận cùng tham chiếu sẽ không cập nhật {nhưng chỉ component đó re-render, component con nhận cùng tham chiếu sẽ không cập nhật}.

2.2. Cạm bẫy class fields — lỗi reactivity thầm lặng {The class fields footgun — silent loss of reactivity}

This is the #1 silent bug in Lit {Đây là bug thầm lặng số 1 trong Lit}: a reactive property is defined as an accessor on the prototype, but a plain class field is defined on the instance — and per JavaScript rules, the instance field shadows the accessor, so setting the property never triggers an update {một reactive property được định nghĩa là accessor trên prototype, còn class field thường nằm trên instance — theo luật JavaScript, field instance che mất accessor, nên gán property không bao giờ trigger update}.

This bites when useDefineForClassFields is true (the default with standard decorators / TS targets ES2022+) {Vấn đề xảy ra khi useDefineForClassFieldstrue (mặc định với standard decorators / TS target ES2022+)}. Three correct patterns {Ba cách viết đúng}:

// ✅ Cách 1 (khuyến nghị, Lit 3 + TS 5): standard decorators + `accessor`.
// `accessor` biến field thành getter/setter → Lit hook vào được.
class A extends LitElement {
  @property() accessor name = 'world';
  @state() accessor _count = 0;
}

// ✅ Cách 2 (JS thuần / static properties): KHÔNG dùng class field,
// khởi tạo trong constructor.
class B extends LitElement {
  static properties = { name: { type: String } };
  constructor() { super(); this.name = 'world'; }
}

// ✅ Cách 3 (TS, experimentalDecorators): tắt useDefineForClassFields
// trong tsconfig → class field giữ nguyên cú pháp mà vẫn reactive.
class C extends LitElement {
  @property() name = 'world'; // OK khi useDefineForClassFields=false
}
// ❌ SAI: useDefineForClassFields=true + class field thường (không `accessor`)
//    → set this.value KHÔNG re-render. Im lặng, rất khó debug.
class Broken extends LitElement {
  @property() value = 0; // field này che mất accessor Lit sinh ra
}

Quy tắc nhanh {Rule of thumb}: dùng standard decorators thì luôn kèm accessor; dùng experimentalDecorators thì đặt "useDefineForClassFields": false. Nếu thấy “đổi property mà UI không cập nhật” → 90% là dính cạm bẫy này. {Nếu property đổi mà UI không cập nhật, gần như chắc chắn là lỗi này.}

2.3. Custom accessor — khi cần validate lúc set {Custom accessor — validate on set}

Lit tự sinh getter/setter, nhưng bạn có thể tự viết để validate/normalize đồng bộ ngay khi set {Lit tự sinh getter/setter, nhưng bạn có thể tự viết để validate/normalize đồng bộ ngay khi set}. Đặt decorator trên setter {Put the decorator on the setter}:

private _size = 0;

@property({ type: Number })
set size(v: number) {
  const next = Math.max(0, Math.floor(v));   // chặn số âm + làm tròn
  const old = this._size;
  this._size = next;
  this.requestUpdate('size', old);            // báo Lit để update
}
get size() { return this._size; }

Hầu hết trường hợp không cần custom accessor {Most of the time you don’t need a custom accessor}: tính giá trị dẫn xuất thì dùng willUpdate (mục 6), phản ứng sau render thì dùng updated — chỉ tự viết setter khi bắt buộc validate đồng bộ tại thời điểm gán {tính giá trị dẫn xuất dùng willUpdate, phản ứng sau render dùng updated; chỉ tự viết setter khi bắt buộc validate đồng bộ}.


3. Templates & 5 kiểu binding {Templates & the 5 binding types}

The html tagged template returns a TemplateResult — a description of DOM, not DOM itself {Tagged template html trả về một TemplateResult — một mô tả DOM, không phải DOM thật}. Lit parses the static parts once and only updates the dynamic ${...} holes on each render {Lit parse phần tĩnh một lần và chỉ cập nhật các “lỗ” động ${...} mỗi lần render}.

Binding position quyết định loại binding — đây là điểm cốt lõi của Lit {Vị trí của binding quyết định loại binding — đây là điểm cốt lõi của Lit}:

render() {
  return html`
    <!-- 1. Text/child binding: nội dung element -->
    <h1>${this.title}</h1>
    <ul>${this.items.map((i) => html`<li>${i}</li>`)}</ul>

    <!-- 2. Attribute binding: attr=${...} → set ATTRIBUTE (string) -->
    <img src=${this.url} alt=${this.alt} />
    <div class="card ${this.variant}"></div>

    <!-- 3. Property binding: .prop=${...} → set PROPERTY (giữ nguyên kiểu) -->
    <input .value=${this.text} />
    <my-list .items=${this.items}></my-list>   <!-- truyền array, KHÔNG stringify -->

    <!-- 4. Boolean attribute: ?attr=${...} → thêm/xóa attribute theo truthy -->
    <button ?disabled=${this.loading}>Lưu</button>

    <!-- 5. Event binding: @event=${...} → addEventListener -->
    <button @click=${this.onSave}>Lưu</button>
    <input @input=${(e) => (this.text = e.target.value)} />
  `;
}
Cú phápLoạiTương đương DOMKhi nào dùng
${value}Text/childnode.textContent / chèn nodeHiển thị nội dung
attr=${value}AttributesetAttribute('attr', value)Giá trị là chuỗi (class, src, id)
.prop=${value}Propertyel.prop = valueTruyền object/array/number giữ kiểu
?attr=${value}Boolean attrtoggleAttribute('attr', !!value)disabled, hidden, checked
@event=${handler}EventaddEventListener('event', h)Bắt sự kiện

The .prop vs attr= distinction is the #1 thing beginners get wrong {Phân biệt .prop với attr= là lỗi số 1 của người mới}: to pass an array/object to a child component, you must use .prop {để truyền array/object cho component con, bạn phải dùng .prop}; attr= would stringify it to "[object Object]" {attr= sẽ biến nó thành "[object Object]"}.

Event handlers keep this bound automatically {Event handler giữ this tự động} when written as arrow-function class fields or referenced as methods — Lit binds the handler’s this to the host element {khi viết dạng class field arrow hoặc tham chiếu method — Lit gắn this của handler vào host element}.


4. Directives — logic tái dùng trong template {Directives — reusable logic in templates}

A directive is a function that customizes how an expression renders {Directive là một hàm tùy biến cách một biểu thức được render}. Lit ships many; import each from its own path (tree-shakeable) {Lit có sẵn nhiều cái; import từng cái theo đường dẫn riêng (tree-shake được)}.

4.1. Conditionals & loops {Điều kiện & vòng lặp}

import { when } from 'lit/directives/when.js';
import { choose } from 'lit/directives/choose.js';
import { map } from 'lit/directives/map.js';
import { repeat } from 'lit/directives/repeat.js';

render() {
  return html`
    <!-- when: if/else khai báo -->
    ${when(this.loggedIn,
      () => html`<user-menu></user-menu>`,
      () => html`<login-button></login-button>`)}

    <!-- choose: switch/case -->
    ${choose(this.status, [
      ['loading', () => html`<spinner></spinner>`],
      ['error',   () => html`<p>Lỗi rồi.</p>`],
    ], () => html`<p>Sẵn sàng.</p>`)}

    <!-- map: vòng lặp đơn giản, KHÔNG diff (nhanh, nhẹ) -->
    <ul>${map(this.tags, (t) => html`<li>${t}</li>`)}</ul>

    <!-- repeat: vòng lặp CÓ KEY → giữ ổn định DOM khi list đổi thứ tự -->
    <ul>${repeat(this.items, (item) => item.id,
      (item) => html`<li>${item.name}</li>`)}</ul>
  `;
}

map vs repeat — câu hỏi hay gặp {a common question}: map re-renders DOM in place (no diffing) — smaller and faster {render DOM tại chỗ, không diff — nhỏ và nhanh hơn}. Use repeat with a keyFn only when the list reorders and you need to preserve DOM state (focus, input, animation) per item {Dùng repeat kèm keyFn chỉ khi list đổi thứ tự và bạn cần giữ trạng thái DOM theo từng item}.

4.2. Attributes & styling directives {Directive cho attribute & styling}

import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';

render() {
  return html`
    <!-- classMap: bật/tắt class theo object -->
    <div class=${classMap({ active: this.active, disabled: this.loading })}></div>

    <!-- styleMap: inline style theo object (camelCase) -->
    <div style=${styleMap({ color: this.color, marginTop: '8px' })}></div>

    <!-- ifDefined: BỎ attribute nếu giá trị undefined/null (tránh src="undefined") -->
    <img src=${ifDefined(this.maybeUrl)} />

    <!-- live: so với GIÁ TRỊ THẬT trong DOM, không phải lần render trước
         → cần cho <input> khi user gõ làm DOM value lệch khỏi state -->
    <input .value=${live(this.text)} @input=${this.onInput} />
  `;
}

4.3. Performance & async directives {Directive hiệu năng & bất đồng bộ}

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

render() {
  return html`
    <!-- guard: chỉ tính lại template khi deps đổi (memo cho phần đắt) -->
    ${guard([this.items], () => this.items.map((i) => heavyRender(i)))}

    <!-- cache: giữ DOM của template không hiển thị để swap qua lại nhanh -->
    ${cache(this.tab === 'a'
      ? html`<panel-a></panel-a>`
      : html`<panel-b></panel-b>`)}

    <!-- until: hiện placeholder cho tới khi promise resolve -->
    ${until(this.dataPromise, html`<spinner></spinner>`)}

    <!-- keyed: ép tạo DOM mới khi key đổi (reset state element con) -->
    ${keyed(this.userId, html`<user-profile .id=${this.userId}></user-profile>`)}
  `;
}

Other built-ins worth knowing {Các directive khác đáng biết}: ref (lấy tham chiếu element), join, range, unsafeHTML (render HTML thô — cẩn thận XSS), asyncAppend/asyncReplace (stream từ async iterator).

import { ref, createRef } from 'lit/directives/ref.js';

class MyEl extends LitElement {
  #canvas = createRef<HTMLCanvasElement>();
  render() { return html`<canvas ${ref(this.#canvas)}></canvas>`; }
  firstUpdated() { const ctx = this.#canvas.value!.getContext('2d'); }
}

4.4. Custom directive — tự viết {Writing a custom directive}

When built-ins aren’t enough, write your own {Khi directive có sẵn không đủ, hãy tự viết}. Extend Directive, implement render (and update if you need DOM access) {Kế thừa Directive, implement render (và update nếu cần truy cập DOM)}:

import { Directive, directive, type PartInfo, PartType } from 'lit/directive.js';

// Directive nối chuỗi với separator — ví dụ tối giản.
class JoinDirective extends Directive {
  render(items: string[], sep = ', ') {
    return items.join(sep);
  }
}
export const join = directive(JoinDirective);

// Dùng: html`<p>${join(this.tags, ' · ')}</p>`

For directives that must persist state across renders or clean up (timers, observers), extend AsyncDirective and use this.setValue() / disconnected() {Cho directive cần giữ state qua nhiều lần render hoặc dọn dẹp, kế thừa AsyncDirective và dùng this.setValue() / disconnected()}.


5. Events — phát và lắng nghe {Events — dispatch and listen}

Listen with @event inside templates; dispatch a CustomEvent to talk to parents {Lắng nghe bằng @event trong template; phát CustomEvent để nói với cha}. Remember composed: true to cross the shadow boundary (see the Web Components post) {Nhớ composed: true để vượt ranh giới shadow}:

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

@customElement('rating-stars')
export class RatingStars extends LitElement {
  @property({ type: Number }) value = 0;

  #select(n: number) {
    this.value = n;
    // Event công khai → bubbles + composed.
    this.dispatchEvent(new CustomEvent('rating-change', {
      detail: { value: n }, bubbles: true, composed: true,
    }));
  }

  // @eventOptions: truyền addEventListener options (passive, capture, once).
  @eventOptions({ passive: true })
  private _onScroll() { /* ... */ }

  render() {
    return html`
      ${[1, 2, 3, 4, 5].map((n) => html`
        <button @click=${() => this.#select(n)}>
          ${n <= this.value ? '★' : '☆'}
        </button>
      `)}
    `;
  }
}
// Cha lắng nghe như event thường:
html`<rating-stars @rating-change=${(e) => (this.score = e.detail.value)}></rating-stars>`

6. Vòng đời update — khi nào code của bạn chạy {The reactive update lifecycle}

When a reactive property changes, Lit schedules an async batched update (microtask) so multiple changes in one tick cause a single render {Khi một reactive property đổi, Lit lên lịch một update gộp bất đồng bộ (microtask) để nhiều thay đổi trong một tick chỉ gây một lần render}.

 property đổi
     │  requestUpdate()  (tự gọi)

 shouldUpdate(changed)   → return false để HỦY update

 willUpdate(changed)     → tính derived state TRƯỚC render (không đụng DOM)

 update(changed)         → phản chiếu attribute, rồi gọi render()

 render()                → trả TemplateResult (hàm thuần, không side effect)

 firstUpdated(changed)   → CHẠY 1 LẦN sau render đầu (DOM đã có → query/đo)

 updated(changed)        → sau MỖI render (reaction tới DOM mới)

 updateComplete (Promise)→ await để biết DOM đã cập nhật xong
class Chart extends LitElement {
  @property({ type: Array }) data: number[] = [];

  // Tính derived state trước render — KHÔNG gây re-render vòng lặp.
  willUpdate(changed: Map<string, unknown>) {
    if (changed.has('data')) this._max = Math.max(...this.data);
  }

  // DOM đã tồn tại — nơi đúng để khởi tạo thư viện cần element thật.
  firstUpdated() {
    this._chart = new SomeChartLib(this.renderRoot.querySelector('canvas')!);
  }

  // Phản ứng mỗi lần đổi — vd vẽ lại chart.
  updated(changed: Map<string, unknown>) {
    if (changed.has('data')) this._chart?.setData(this.data);
  }

  async _afterRender() {
    await this.updateComplete; // đảm bảo DOM mới đã apply
  }
}

Key distinctions {Phân biệt then chốt}: willUpdate for computing derived values (runs before render, no DOM yet) {willUpdate để tính giá trị dẫn xuất (chạy trước render, chưa có DOM)}; firstUpdated for one-time DOM setup {firstUpdated cho thiết lập DOM một lần}; updated for reacting to every change {updated để phản ứng mọi thay đổi}. Call this.requestUpdate() manually only when state lives outside reactive properties {Chỉ gọi this.requestUpdate() thủ công khi state nằm ngoài reactive properties}.


7. Styling — scoped & themeable {Styling — scoped & themeable}

static styles with the css tag is parsed once and shared via Constructable Stylesheets {static styles với tag css được parse một lần và chia sẻ qua Constructable Stylesheets}:

import { css, LitElement } from 'lit';

class Card extends LitElement {
  static styles = css`
    :host { display: block; padding: 16px; border-radius: 12px;
            background: var(--card-bg, #fff); }   /* token theme từ ngoài */
    :host([elevated]) { box-shadow: 0 4px 20px rgb(0 0 0 / .1); }
    ::slotted(h2) { margin: 0; }                  /* style nội dung được slot */
    .body::part(x) { }                            /* expose part ra ngoài nếu cần */
  `;
}
  • Mảng styles {Style arrays}: static styles = [reset, shared, local] để tái dùng {để tái dùng}.
  • Theming đi qua CSS custom properties vì chúng xuyên ranh giới shadow {vì chúng xuyên qua ranh giới shadow} — nguồn duy nhất nên dùng để cho phép user tùy biến {nguồn duy nhất nên dùng để cho phép user tùy biến}.
  • Dynamic styles {Style động} per render dùng classMap/styleMap (mục 4), không nhồi vào static styles {không nhồi vào static styles}.

8. Slots & composition {Slots & composition}

Lit uses the same native <slot> mechanism {Lit dùng đúng cơ chế <slot> gốc}:

render() {
  return html`
    <div class="dialog">
      <header><slot name="title">Không tiêu đề</slot></header>
      <div class="content"><slot></slot></div>     <!-- slot mặc định -->
      <footer><slot name="actions"></slot></footer>
    </div>
  `;
}
<my-dialog>
  <h2 slot="title">Xác nhận</h2>
  <p>Bạn chắc chứ?</p>
  <button slot="actions">OK</button>
</my-dialog>

Read slotted nodes with @queryAssignedElements {Đọc các node được slot bằng @queryAssignedElements}:

import { queryAssignedElements } from 'lit/decorators.js';

class MyTabs extends LitElement {
  @queryAssignedElements({ slot: '', selector: 'my-tab' })
  private _tabs!: HTMLElement[];
}

9. Reactive Controllers — chia sẻ logic có vòng đời {Reactive Controllers — share lifecycle-aware logic}

A Reactive Controller is a reusable object that hooks into a host’s update lifecycle — Lit’s answer to “composition over inheritance” (similar role to React hooks) {Reactive Controller là một object tái dùng móc vào vòng đời update của host — câu trả lời của Lit cho “composition hơn inheritance” (vai trò giống React hooks)}:

import { ReactiveController, ReactiveControllerHost } from 'lit';

// Controller theo dõi kích thước cửa sổ, tự cleanup theo vòng đời host.
export class WindowSizeController implements ReactiveController {
  width = window.innerWidth;
  #host: ReactiveControllerHost;

  constructor(host: ReactiveControllerHost) {
    this.#host = host;
    host.addController(this); // đăng ký với host
  }

  hostConnected() { window.addEventListener('resize', this.#onResize); }
  hostDisconnected() { window.removeEventListener('resize', this.#onResize); }

  #onResize = () => {
    this.width = window.innerWidth;
    this.#host.requestUpdate(); // báo host re-render
  };
}
class Responsive extends LitElement {
  // Một dòng — mọi logic resize + cleanup gói gọn, tái dùng ở component khác.
  #size = new WindowSizeController(this);
  render() { return html`<p>Rộng: ${this.#size.width}px</p>`; }
}

Lit ecosystem packages build on this pattern {Các package trong hệ sinh thái Lit xây trên mẫu này}: @lit/task (Task controller cho async data + trạng thái pending/complete/error) {cho dữ liệu async + trạng thái}, and @lit/context (@provide/@consume để truyền data xuống cây không cần prop-drilling) {để truyền data xuống cây không cần prop-drilling}.


Tying it all together {Ghép tất cả lại}: reactive properties, all binding types, directives, events, lifecycle, and styling {reactive properties, mọi kiểu binding, directive, event, vòng đời, và styling}.

// search-box.ts
import { LitElement, html, css } from 'lit';
import { customElement, state, property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { when } from 'lit/directives/when.js';
import { classMap } from 'lit/directives/class-map.js';

interface Result { id: string; title: string; }

@customElement('search-box')
export class SearchBox extends LitElement {
  static styles = css`
    :host { display: block; max-width: 420px; font: 14px system-ui; }
    input { width: 100%; padding: 8px 12px; border-radius: 8px;
            border: 1px solid var(--border, #ccc); }
    ul { list-style: none; margin: 4px 0 0; padding: 0; }
    li { padding: 8px 12px; cursor: pointer; border-radius: 6px; }
    li.active { background: var(--accent, #eef); }
    .empty { color: #888; padding: 8px 12px; }
  `;

  @property() placeholder = 'Tìm kiếm…';
  @state() private _query = '';
  @state() private _results: Result[] = [];
  @state() private _loading = false;
  @state() private _active = -1;

  #debounce = 0;

  render() {
    return html`
      <input
        type="search"
        .value=${this._query}
        placeholder=${this.placeholder}
        ?disabled=${this._loading}
        @input=${this.#onInput}
        @keydown=${this.#onKey}
      />
      ${when(this._loading,
        () => html`<div class="empty">Đang tải…</div>`,
        () => this.#renderResults())}
    `;
  }

  #renderResults() {
    if (!this._query) return html``;
    if (this._results.length === 0)
      return html`<div class="empty">Không có kết quả.</div>`;
    return html`
      <ul>
        ${repeat(this._results, (r) => r.id, (r, i) => html`
          <li
            class=${classMap({ active: i === this._active })}
            @click=${() => this.#choose(r)}
          >${r.title}</li>
        `)}
      </ul>
    `;
  }

  #onInput = (e: Event) => {
    this._query = (e.target as HTMLInputElement).value;
    clearTimeout(this.#debounce);
    // Debounce 250ms để không bắn request mỗi phím.
    this.#debounce = window.setTimeout(() => this.#fetch(), 250);
  };

  #onKey = (e: KeyboardEvent) => {
    if (e.key === 'ArrowDown') this._active = Math.min(this._active + 1, this._results.length - 1);
    else if (e.key === 'ArrowUp') this._active = Math.max(this._active - 1, 0);
    else if (e.key === 'Enter' && this._results[this._active]) this.#choose(this._results[this._active]);
  };

  async #fetch() {
    if (!this._query) { this._results = []; return; }
    this._loading = true;
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(this._query)}`);
      this._results = await res.json();       // gán tham chiếu mới → re-render
      this._active = -1;
    } finally {
      this._loading = false;                    // state đổi → tự render lại
    }
  }

  #choose(r: Result) {
    this.dispatchEvent(new CustomEvent('select', {
      detail: r, bubbles: true, composed: true,
    }));
    this._query = r.title;
    this._results = [];
  }
}
<search-box placeholder="Tìm sản phẩm…"></search-box>
<script type="module" src="/search-box.js"></script>
<script type="module">
  document.querySelector('search-box')
    .addEventListener('select', (e) => console.log('Đã chọn:', e.detail));
</script>

Kết luận {Conclusion}

Lit keeps the standards-based, framework-agnostic, long-lived nature of Web Components, and removes the boilerplate {Lit giữ bản chất dựa trên chuẩn, độc lập framework, sống lâu của Web Components, và bỏ đi phần lặp}. The mental model is small {Mô hình tư duy nhỏ gọn}: reactive properties hold state, html describes UI, the five binding types wire data in, directives add reusable logic, and the update lifecycle gives you precise hooks {reactive properties giữ state, html mô tả UI, năm kiểu binding nối dữ liệu vào, directive thêm logic tái dùng, và vòng đời update cho bạn các hook chính xác}.

Tham khảo {References}: