Guide

Connecting to a backend

Every component in the kit that renders a collection speaks the same four types, so wiring a table, a kanban board and a notification feed to your API is one shape of work, not three. This page is that shape — and it ends with a table you can break on purpose.

The contract

Four types from @styloviz/core. No HTTP, no rxjs, no opinion about where your data comes from — a component that imported HttpClient would be unusable from tRPC, GraphQL or a WebSocket, which is most of the reason this layer exists.

SvDataQuery
What the user is asking for: page, page size, sorts, filters, search, cursor. Emitted as one object, so you write one handler however they changed it.
SvPage<T>
What came back: items, total and cursor. A null total means the server did not say — which is not the same as zero.
SvDataError
What went wrong, already safe to render. toSvDataError() derives retryable from the status, so a 404 never gets a button that can only fail again.
SvItemKey<T>
How a row keeps its identity across refreshes. Tracking by index means a reorder moves focus to the wrong element and shows the wrong row as selected.

The short version

@styloviz/data is optional sugar — no component depends on it. What it saves you is the four things a hand-rolled subscribe tends to forget.

Install

terminal.shCode
npm i @styloviz/data

A server-backed table

The component knows nothing about HTTP; the controller knows nothing about tables.

invoices.component.tsTypeScript
import { Component } from '@angular/core';
import { SvDataTableComponent } from '@styloviz/data-table';
import { svDataController, createHttpDataSource } from '@styloviz/data';

@Component({
  selector: 'app-invoices',
  imports: [SvDataTableComponent],
  template: `
    <sv-data-table
      [serverSide]="true"
      [columns]="columns"
      [rows]="data.items()"
      [loading]="data.loading()"
      [error]="data.error()"
      [totalCount]="data.total()"
      (queryChange)="data.setQuery($event)"
      (retry)="data.reload()" />
  `,
})
export class InvoicesComponent {
  readonly columns = [
    { key: 'customer', label: 'Customer', sortable: true },
    { key: 'amount', label: 'Amount', sortable: true },
  ];

  readonly data = svDataController(
    createHttpDataSource<Invoice>({ url: '/api/invoices' }),
  );
}
  • 1Debounce. Typing five characters is one request. A page or sort click is not debounced at all — that is a deliberate act, not a stream of keystrokes, and waiting 300 ms after it just feels broken.
  • 2A request guard. The user types ab, then abc. Two requests go out; ab is slower and lands second. Without a guard the list ends up showing results for a query the user has already moved past — and nothing in the code looks wrong.
  • 3Real cancellation. Ignoring a superseded response is not enough: the request keeps running and holds a connection. On a slow link that is exactly what makes filtering feel laggy.
  • 4Retry that knows when not to. Network failures, 429 and 5xx are retried with jittered backoff. A 404 or a 422 is surfaced immediately — retrying those only delays telling the user something they need to act on.

Matching your API

Three presets cover most REST layers. None of them asks you to change the server.

Query shape

sources.tsTypeScript
import { offsetLimitQuery, pageNumberQuery, cursorQuery } from '@styloviz/data';

// ?offset=40&limit=20 — the default, and what SQL LIMIT/OFFSET maps to.
createHttpDataSource({ url: '/api/invoices', mapQuery: offsetLimitQuery() });

// ?page=3&perPage=20 — 1-based, as Rails, Laravel and JSON:API emit.
createHttpDataSource({ url: '/api/invoices', mapQuery: pageNumberQuery() });

// ?cursor=abc&limit=20 — keyset. The cursor is omitted on the first page,
// and dropped automatically whenever the sort or filters change: a cursor is
// only meaningful against the ordering that produced it.
createHttpDataSource({ url: '/api/invoices', mapQuery: cursorQuery() });

// Anything else is a pure function of the query.
const mapQuery = (q) => ({ skip: String((q.page - 1) * q.pageSize), take: String(q.pageSize) });

Response shape

sources.tsTypeScript
// Recognised without configuration: a bare array, or an object with the rows
// under items / data / results / records / rows, and the count under
// total / totalCount / count / meta.total.
createHttpDataSource({ url: '/api/invoices' });

// Anything else — one function, no server changes.
createHttpDataSource({
  url: '/api/invoices',
  mapResponse: (body) => ({
    items:  body.payload.edges.map((e) => e.node),
    total:  body.payload.totalCount,
    cursor: body.payload.pageInfo.endCursor ?? null,
  }),
});

Without the adapter package

The contract is three inputs and two outputs. If you already have a data layer you like, keep it — you are not giving anything up except the four items above, which you can also write yourself.

Plain signals

invoices.component.tsTypeScript
// No adapter package required — the contract is just three inputs.
readonly rows    = signal<Invoice[]>([]);
readonly loading = signal(false);
readonly error   = signal<SvDataError | null>(null);

async load(query: SvDataQuery): Promise<void> {
  this.loading.set(true);
  this.error.set(null);
  try {
    this.rows.set(await this.api.invoices(query));
  } catch (e) {
    // Derives `retryable` from the status, so a 404 gets no "Try again".
    this.error.set(toSvDataError(e));
  } finally {
    this.loading.set(false);
  }
}

Live feeds

Streaming components take events one at a time rather than a rebuilt array per token, and they take a connection state they cannot infer: a list that stops growing looks identical whether the agent is thinking or the socket died ten minutes ago. Users resolve that ambiguity by reloading — which on a stream loses everything that already arrived.

An EventSource into an activity log

run-page.component.tsTypeScript
// A live feed appends; it does not hand you a new array per token.
@ViewChild(SvAiAgentActivityLogComponent) log!: SvAiAgentActivityLogComponent;

private open(): void {
  const es = new EventSource('/api/runs/42/events');
  es.onopen    = () => this.connection.set('open');
  es.onerror   = () => this.connection.set('reconnecting');
  es.onmessage = (m) => this.log.appendEvent(JSON.parse(m.data));
}

Break it on purpose

A real <sv-data-table> over 84 rows, behind an artificial network. Add latency and watch the skeleton, the debounce and the request guard become visible; force a failure and watch what survives it.

retryable — a Try again button appears

Invoices

84
Invoice Customer Status Amount
1000 Northwind Traders paid 120
1001 Acme Logistics open 157
1002 Blue Harbour Ltd overdue 194
1003 Contoso Retail paid 231
1004 Fabrikam Devices open 268
1005 Globex Media overdue 305
1006 Initech Systems paid 342
1007 Umbra Analytics open 379
1008 Northwind Traders overdue 416
1009 Acme Logistics paid 453

Showing 1 – 8 of 84

How the demo fakes a network

The same decorators are exported for your own tests.

demo.component.tsTypeScript
import { createArrayDataSource, withLatency, withFailure } from '@styloviz/data';

// The states that never appear against a local array — the skeleton, the
// stale-while-loading table, the superseded request — made reachable.
readonly source = withFailure(
  withLatency(createArrayDataSource(INVOICES), () => this.latencyMs()),
  { when: () => this.broken(), status: 503 },
);

Things worth trying: search while latency is high and watch the rows stay put rather than blanking; turn on failure mid-search and note that the toolbar and the rows survive, so you can undo the filter that caused it; switch 503 to 404 and watch the retry button disappear.