xoong
Menu · How it works
DocsStartHow it works

How it works

xoong has three layers, and you can stop at any of them: a reactive core and base element, a data layer generated from OpenAPI, and ready-made admin parts on top.

How a list on the screen is read Four groups. On the left, the code you write, in a window named people.html, and below it the screen that code makes: a window with a table of people. In the middle, the xoong client, with the query cache above the entity store. On the right, your services and APIs, drawn as example tiles (Orders API, Files service and People API); xoong needs one OpenAPI document and one base URL and does not care what is behind it, and here only the People API is called. The code window shows people.html: <xo-view get="listPeople"> <xo-table columns="name,email,role" sortable></xo-table> </xo-view> <!-- loading, errors, cache and types come from your OpenAPI document -->. Steps: 1. The xo-view with get="listPeople" asks the client for that operation. Until the answer comes, the screen shows loading rows. 2. The client's query cache has no fresh answer, so it sends GET /people to your People API. You wrote no request code. 3. Your People API answers 200 with the people as JSON. 4. The client stores each person once, as one record per id, in the entity store. 5. Effects write the rows into the xo-table on the screen. Only the DOM nodes that read the new data change, and the loading rows are gone. When the animation plays, a dot travels the arrows in numbered order while the line of code that causes the step is lit, and the screen changes with it: loading rows first, then the table. A hollow dot rides a dashed arrow. is drawn as You write only the code on the left. The client does the rest, from your OpenAPI document. The client (@xoong/data) Query cacheone query per operation Entity storeone signal per record Your services and APIsany language, one base URL,described by OpenAPI Orders API Files service People APIGET /people 1 get 2 GET /people 3 200 JSON 4 normalize 5 effects

A read, from the markup you write to the rows on the screen. Dashed: an answer, or the store telling the screen. The people and the names of the services are examples. In motion, a dot travels the arrows in numbered order while the line of code that causes the step is lit, and the screen changes with it: loading rows first, then the table. A hollow dot rides a dashed arrow. Use the slider, or press a number, to look at one step.

The picture is the whole idea. The few lines on the left are all you write; they make the screen. The client asks your services for the data, keeps each record once, and writes only the DOM nodes that show it. A click goes the same road and ends by refreshing what it touched: see Actions and forms and the Data flow page, which has every step.

Data

xoong sync turns your OpenAPI document into operations.json. A <xo-view get="listPeople"> calls the operation, caches the answer in a shared store (records with the same id are shared between screens) and exposes $loading, $error and the data to the markup inside it. You never write a cache key, an HTTP client or an error handler.

<xo-view get="listPeople">
  <p xo-if="$loading">Loading…</p>
  <p xo-if="$error" role="alert">{$error.code}: {$error.detail}</p>
  <ul>
    <li xo-each="items">{name} <small>{email}</small></li>
  </ul>
</xo-view>
npx xoong sync openapi.json

Actions and forms

Writes are attributes too. A button runs an operation; after it succeeds, every cached query whose path overlaps it is refetched, so lists stay current without extra code. A form with <xo-form op="createPerson"> is drawn from the request schema: nested objects, arrays and unions included. Validation comes from the same schema, with your own rules by path.

How a click on the screen is written Four groups. On the left, the code you write, in a window named people.html: the list from before, with a Disable button in each row that has xo-click and xo-confirm; below it the screen that code makes, a table of people with a Disable button in each row. In the middle, the xoong client, with client.mutate above the query cache above the entity store. On the right, your services and APIs, drawn as example tiles; only the People API is called. The code window shows people.html: <xo-view get="listPeople"> <xo-table columns="name,status"> <button xo-click="POST /people/{id}/disable" xo-confirm="Disable {name}?" >Disable</button> </xo-table> </xo-view>. Steps: 1. You click Disable on a row. The button's xo-click starts client.mutate, and xo-confirm asks first: "Disable Ada?" You press OK. 2. mutate sends POST /people/1/disable to your People API. The {id} comes from the row. 3. Your People API answers 200 with the updated person. 4. mutate puts the answer in the entity store. The row reads that record, so its effect writes "disabled" at once. 5. mutate marks every cached query whose path overlaps /people/1/disable as stale. The list is one, and the screen is showing it, so it is fetched again now. 6. The cache asks your People API for the list again: GET /people. The rows stay on the screen meanwhile. 7. Your People API answers with the list as it is now. 8. The client stores the new answer: the same records, one each. 9. Effects update the screen. Nothing visible changes here, because the row already said "disabled". When the animation plays, a dot travels the arrows in numbered order while the code that causes the step is lit; the screen shows the question, then the changed row. A hollow dot rides a dashed arrow. is drawn as You write only the code on the left. The client does the rest, from your OpenAPI document. client.mutate()POST, then invalidate Query cacheone query per operation Entity storeone signal per record The client (@xoong/data) Your services and APIsany language, one base URL,described by OpenAPI Orders API Files service People APIGET /peoplePOST /people/{id}/disable 1 click 2 POST 3 200 4 stored 5 invalidate 6 GET 7 200 JSON 8 normalize 9 effects

A write, from the click to the screen. The client then reads the list again, because the write touched its path. Dashed: an answer, or the client telling the screen. The people and the names of the services are examples. In motion, a dot travels the arrows in numbered order while the code that causes the step is lit; the screen shows the question, then the changed row. A hollow dot rides a dashed arrow. Use the slider, or press a number, to look at one step.

A form drawn from the request schema Four groups. On the left, the code you write, in a window named people.html: an xo-form with op="createPerson" and a submit label; below it the screen that code makes, a window with a form of two fields and a button. In the middle, the xoong parts: operations.json with the request schema of createPerson, the form model, and client.mutate. On the right, your services and APIs, drawn as example tiles; only the People API is called. The code window shows people.html: <xo-form op="createPerson" submit="Add person"></xo-form> <!-- fields and validation come from the request schema of createPerson -->. Steps: 1. The xo-form with op="createPerson" reads the request schema of createPerson from operations.json and draws a control for each field. 2. You type a name and an invalid e-mail address, and press Add person. The form model takes the values. 3. The model checks the values against the same request schema. The e-mail is not valid, so nothing is sent: the field shows its error. 4. You correct the e-mail address and press Add person again. 5. The values are valid now. The form model hands them to client.mutate. 6. mutate sends POST /people to your People API, with the values as the JSON body. 7. Your People API answers with the new person. 8. The call worked: the form clears, ready for the next person. A list of people on the page, whose path overlaps POST /people, is fetched again. When the animation plays, a dot travels the arrows in numbered order while the code that causes the step is lit; the screen shows an empty form, the typed values, the error, the corrected value, and the cleared form. A hollow dot rides a dashed arrow. is drawn as You write only the code on the left. The client does the rest, from your OpenAPI document. xoong operations.jsoncreatePerson: its schema Form modelchecks values by the schema client.mutate()sends createPerson Your services and APIsany language, one base URL,described by OpenAPI Orders API Files service People APIPOST /people 1 draws 2, 4 submit 3 error 5 valid 6 POST 7 answer 8 done

A form is read from the schema, not written. Nothing is sent until the values are valid. Dashed: an answer, or the model telling the screen. The names of the services are examples. In motion, a dot travels the arrows in numbered order while the code that causes the step is lit; the screen shows an empty form, the typed values, the error, the corrected value, and the cleared form. A hollow dot rides a dashed arrow. Use the slider, or press a number, to look at one step.

<button xo-click="POST /api/people/{id}/disable" xo-confirm="Disable {name}?" xo-notice="Disabled">Disable</button>

<xo-form op="createPerson" submit="Add person"></xo-form>
// Only when HTML is not enough: the same client, typed from api.d.ts
import { getClient } from '@xoong/data';

const created = await getClient().mutate('createPerson', {}, { name: 'Ada', email: 'ada@example.org' });

Pages

<xo-router> is a declarative History-API router with nested layouts, guards and a "leave this page?" question for unsaved forms. Public pages stay one HTML file each and can be prerendered with @xoong/ssr.

<xo-router base="/admin">
  <template xo-layout="shell">
    <nav><a href="/people">People</a></nav>
    <main xo-outlet></main>
  </template>
  <template xo-route="/people/:id" layout="shell" title="Person">
    <xo-view get="getPerson" p-id=":id">...</xo-view>
  </template>
  <template xo-route="*">Not found</template>
</xo-router>

Language

@xoong/i18n uses plain JSON catalogs and ICU-style messages. xo-t translates markup, and every built-in string (table, forms, controls) is a catalog key, Vietnamese included.

<h1 xo-t="home.title">Welcome</h1>
<input xo-t="placeholder:search.placeholder" placeholder="Search">
import { configureI18n, setLocale } from '@xoong/i18n';

configureI18n({ locale: 'en', load: (locale) => fetch(`/i18n/${locale}.json`).then((r) => r.json()) });
await setLocale('vi');

Safety

xoong build rewrites imports to files and removes the need for inline code, so the output runs under default-src 'none'; script-src 'self'; style-src 'self'. This site is built that way, and its tests fail on any CSP violation. Interpolated text is escaped, javascript: URLs become about:blank, and rich text is sanitised on every input.

Packages

Install only what you use. Sizes are gzip budgets that the tests enforce.

PackageWhat it isBudget
@xoong/coreSignals, html templates, XoElement, XoControl5.9 KB
@xoong/dataTyped client, entity store, <xo-view>, live updates19.8 KB
@xoong/formsForm model and binder: validation, arrays, dirty state9.5 KB
@xoong/uixoong.css tokens, table, generated form, dialogs (confirm, alert, prompt), toast, router13.1 KB
@xoong/controlsTabs, menu, tooltip, combobox, tags, carousel, switch, date, time, slider, stepper, password, copy, image, theme toggle, file, accordion, popover, consent, command, chart37 KB
@xoong/richtextSanitised rich text editor8.5 KB
@xoong/i18nCatalogs, ICU-subset messages, xo-t5.7 KB
@xoong/clisync, check, dev, build, render, i18ntools
@xoong/test, mcp, lsp, ssr, serverMock API and screens for Vitest, an MCP server, editor support, prerendering, the dev and build servertools