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):
<div class="grid md:grid-cols-2 gap-6">
<div dataProvider="${docsUserScopeAKey.path}">
<docs-user></docs-user>
</div>
<div dataProvider="${docsUserScopeBKey.path}">
<docs-user></docs-user>
</div>
</div>
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`
<div class="flex gap-3">
<sonic-image src=${avatar} ...></sonic-image>
<div>${first_name} <b>${last_name}</b></div>
<div class="text-sm text-neutral-400">${email}</div>
</div>
`;
render() {
return html`
<sonic-list fetch dataProvider=${usersListEndpoint.path} key="data" .items=${this.items}></sonic-list>
`;
}
}
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
- Data flow — full map
- DataProviderKey — typed paths and host constraints
- @subscribe / sub() — read-only in templates
- List —
.items,.noItems,.skeleton