Concorde documentation (crawl) · interactive version

Concorde guide — My first component. Build a Lit user card with Tailwind and Concorde UI, then connect it to the DataProvider store: declare the data configuration (type, key, scope), and let @subscribe keep the card in sync. Doc ID: docs/_getting-started/my-first-component. Keywords: Concorde, supersoniks, docs/_getting-started/my-first-component, my-first-component, My first component, guide, @subscribe, src/docs/tailwind.ts, ${unsafeCSS(tailwindImport)}, DocsUserData, user, docsUserRowKey, "${dataProvider}", { dataProvider: string | null }. URL: https://concorde.supersoniks.org/crawl/docs/_getting-started/my-first-component.html.

My first component

Build a Lit user card with Tailwind and Concorde UI, then connect it to the DataProvider store: declare the data configuration (type, key, scope), and let @subscribe keep the card in sync.

Legacy approach with the Subscriber mixin: Legacy: My first subscriber.

1. Lit component + Tailwind

Export Tailwind once (e.g. src/docs/tailwind.ts in this repo):

import { css, unsafeCSS } from "lit";
import tailwindImport from "./css/tailwind.css?inline";

export const tailwind = css`${unsafeCSS(tailwindImport)}`;

User card — plain Lit properties and UI components (no store yet):

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

@customElement("docs-user")
export class DocsUser extends LitElement {
  static styles = [tailwind];

  @property({ type: String }) first_name = "";
  @property({ type: String }) last_name = "";
  @property({ type: String }) email = "";
  @property({ type: String }) avatar = "";

  render() {
    return html`<div class="flex items-center gap-3 rounded-md p-2">
      <sonic-image src=${this.avatar} rounded="md" ratio="1/1" class="w-16"></sonic-image>
      <div>
        <div>${this.first_name} <span class="font-bold">${this.last_name}</span></div>
        <div class="text-sm text-neutral-400">${this.email}</div>
      </div>
    </div>`;
  }
}

2. Data configuration

The card does not hard-code user fields anymore. You declare what is stored, where it lives, and which ancestor scope applies — then @subscribe mirrors that object on the component.

Type — shape of the data

DocsUserData documents the fields you expect at this scope (first name, email, avatar, …). TypeScript checks that user matches that shape.

export type DocsUserData = {
  first_name: string;
  last_name: string;
  email: string;
  avatar: string;
};

Key — path in the DataProvider

docsUserRowKey points at the store segment to read. The path "${dataProvider}" is resolved at runtime from a property on the component (see scope below).

import { DataProviderKey } from "@supersoniks/concorde/core/utils/dataProviderKey";

export const docsUserRowKey = new DataProviderKey<
  DocsUserData,
  { dataProvider: string | null }
>("${dataProvider}");

The second generic ({ dataProvider: string | null }) lists what the host must expose so "${dataProvider}" can be resolved. See DataProviderKey and Dynamic path placeholders.

Scope — which store segment applies

A parent sets dataProvider=${docsUserScopeAKey.path} (or a list row sets …/list-item/0). @ancestorAttribute copies that attribute onto this.dataProvider, so the key resolves to the correct branch.

@ancestorAttribute("dataProvider")
dataProvider: string | null = null;

Subscribe — keep the card in sync

@subscribe watches docsUserRowKey and updates user when the store changes. Use @state() so Lit re-renders. The template reads this.user like any other property.

import { html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
  ancestorAttribute,
  subscribe,
} from "@supersoniks/concorde/core/decorators/Subscriber";
import { docsUserRowKey, type DocsUserData } from "./users";
import { tailwind } from "../tailwind";

@customElement("docs-user")
export class DocsUser extends LitElement {
  static styles = [tailwind];

  @ancestorAttribute("dataProvider")
  dataProvider: string | null = null;

  @subscribe(docsUserRowKey)
  @state()
  user: DocsUserData | null = null;

  render() {
    const u = this.user;
    if (!u) return nothing;
    return html`<div class="flex items-center gap-3 rounded-md p-2">
      <sonic-image src=${u.avatar} rounded="md" ratio="1/1" class="w-16"></sonic-image>
      <div>
        <div>${u.first_name} <span class="font-bold">${u.last_name}</span></div>
        <div class="text-sm text-neutral-400">${u.email}</div>
      </div>
    </div>`;
  }
}

Live code: src/docs/example/users.ts. In this repo, imports use core/… paths (short @supersoniks/concorde/… exports apply in consumer apps only).

Two scopes, one component

The same docs-user is mounted twice. Each copy inherits a different nearest dataProvider — that is the row/list scope:

<docs-demo-sources for="docs-user-two-scopes"></docs-demo-sources>
    <docs-user-two-scopes></docs-user-two-scopes>

Markup (two isolated stores; keys from docs-provider-keys.ts):

&lt;div class="grid md:grid-cols-2 gap-6"&gt;
      &lt;div dataProvider="${docsUserScopeAKey.path}"&gt;
        &lt;docs-user&gt;&lt;/docs-user&gt;
      &lt;/div&gt;
      &lt;div dataProvider="${docsUserScopeBKey.path}"&gt;
        &lt;docs-user&gt;&lt;/docs-user&gt;
      &lt;/div&gt;
    &lt;/div&gt;

Seeded in docs-provider-keys.ts (set(docsUserScopeAKey, …)): Paul / Marie. Without @ancestorAttribute, both cards would not know which store to read.

3. Fetch users — sonic-list with .items

Use a Lit parent and the items renderer (not HTML <template> children). The callback receives each row object from fetch — render fields directly (${item.first_name}, …), like porting a template that used data-bind / <sonic-value>.

<docs-lit-demo for="docs-users-list"></docs-lit-demo>

Wrapper (docs-users-list.ts):

import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import "../../core/components/functional/list/list";
import "./users";

@customElement("docs-users-list")
export class DocsUsersList extends LitElement {
  private items = ({ first_name, last_name, email, avatar }) => html`
    &lt;div class="flex gap-3"&gt;
      &lt;sonic-image src=${avatar} ...&gt;&lt;/sonic-image&gt;
      &lt;div&gt;${first_name} &lt;b&gt;${last_name}&lt;/b&gt;&lt;/div&gt;
      &lt;div class="text-sm text-neutral-400"&gt;${email}&lt;/div&gt;
    &lt;/div&gt;
  `;

  render() {
    return html`
      &lt;sonic-list fetch dataProvider=${usersListEndpoint.path} key="data" .items=${this.items}&gt;&lt;/sonic-list&gt;
    `;
  }
}

See Local API demos for serviceURL="/docs-mock-api".

4. Form preview with formDataProvider

Edit fields in a form; the card follows the same docsUserRowKey when the preview host sets dataProvider="userPreview".

<div class="grid grid-cols-1 gap-4 max-w-xl">
      <form formDataProvider="${docsUserPreviewKey.path}" class="grid grid-cols-2 gap-3">
        <sonic-input label="First name" name="first_name" value="Paul" size="sm"></sonic-input>
        <sonic-input label="Last name" name="last_name" value="Metrand" size="sm"></sonic-input>
        <sonic-input class="col-span-2" label="Email" name="email" value="paul@example.com" size="sm"></sonic-input>
        <sonic-input class="col-span-2" label="Avatar URL" name="avatar" value="https://i.pravatar.cc/150?u=paul" size="sm"></sonic-input>
      </form>
      <sonic-divider align="left">Preview</sonic-divider>
      <div dataProvider="${docsUserPreviewKey.path}">
        <docs-user></docs-user>
      </div>
    </div>

Use formDataProvider + name on inputs — no manual @input handlers (Data flow).

Next steps