Frameworks
React, Vue and Svelte components, and the same elements in Angular, Astro and server-rendered pages without one.
The elements are Web Components, so they work in any page. What a framework adds is a way to
hand them their provider, texts and view the way the framework hands anything to anything —
props, v-model, bind:. Three adapters do that; for everything else there is connect, the
function they are all built on (The public API → Connecting).
| Import | View | |
|---|---|---|
| React 18 and 19 | @casoon/opengrid/react |
view + onViewChange, or defaultView |
| Vue 3.3 and later | @casoon/opengrid/vue |
v-model:view, or defaultView |
| Svelte 5 | @casoon/opengrid/svelte |
bind:view, or defaultView |
| Angular 22 | a directive over connect (below) |
[(view)] |
| Lit, Astro, plain pages | @casoon/opengrid |
connect(host, { view, onViewChange }) |
The adapters are subpaths of the one package, and their frameworks are optional peers:
npm install @casoon/opengrid react (or vue, svelte) and import from
@casoon/opengrid/react. A page that uses none of them installs no framework. See
Installation.
What every adapter does the same way
- Attributes are props in camelCase —
label,datasource,columns,windowSize,groupBy,columnMenu, … — and they are rendered, so they are in the server’s HTML. A boolean attribute is present or absent;selection={false}leaves it out. - Everything else goes to
connect:provider,texts,formats,presentation(whatset_columnstakes — named apart from thecolumnsattribute),choices,actions,validators,view,defaultView, and the events. - A prop that goes away is reset — texts back to English, no formats — and one that was never given is left alone.
- The source is asked once for the first result, in React’s StrictMode too.
- A grid taken out is collected; one that is only moved, or parked by Vue’s
<KeepAlive>, comes back as it was, without asking again.
Four rules to know, because they come from the grid and not from the framework:
provider, format and validator functions compare by identity. A provider created inline —provider={createRestProvider(…)}in a React render — is a new provider on every render, and each one asks the source again. Create it once: outside the component, or inuseMemo, a module-levelconst, a Svelte<script module>.viewis controlled. A view the page does not take back from the change event is written back — after the next render in React, after the tick in Vue and Svelte. WithdefaultViewthe reader leads after the first application.densityandgroupBybelong to the view as well. The reader changes them from the toolbar and the column menu, so as props they set the start. With a controlledview, leave them out and let the view carry them.- Bundlers and the WebAssembly module. Without options,
loadOpengrid()andcreateWorkerProvider()need nothing from the page:loader.jsimports the element module andworker.jsthe engine in a form a bundler follows, and the worker is started withnew Worker(new URL("./worker.js", import.meta.url), { type: "module" }). A production build with Vite’s defaults therefore emits the glue, the worker and both.wasmfiles (tests/e2e/bundler-production.spec.jsloads such a build fromexamples/bundler). What a bundler cannot follow is a URL of your own:loadOpengrid({ moduleUrl })andcreateWorkerProvider({ moduleUrl, workerUrl })import from where you say, at run time, and the files there are yours to serve (for the worker,moduleUrlandwasmUrlare strings). The engine on the main thread, imported from@casoon/opengrid/engine/opengrid_wasm.js, is bundled like any module. The examples in this repository still passmoduleUrl, because they run from the repository’s builtpkg/.
Editing
The grid’s editing reaches a component the same way everything else does: rowKey and addRow
are attributes, actions and validators options, and each event a callback. Two things are
not events:
- A new row is answered by the callback’s return value —
{ state: "saved", key }or{ state: "error", message }, or a promise of one; the row shows saving until it settles, and a rejection is an error with its message (Connecting). setCellStateandsetValues— what became of an edit the page saves, and values it computes — are methods on the component’s ref.
| New row | Other events | setCellState, setValues on |
|
|---|---|---|---|
| React | onRowAdd |
onCellChange, onPaste, onBulkChange, onRowAction, onSourceReplaced |
the ref — the element, carrying both (useRef<OpengridGridRef>) |
| Vue | @row-add — a prop, since it answers |
@cell-change, @paste, @bulk-change, @row-action, @source-replaced |
the template ref (OpengridGridExposed) |
| Svelte | onrowadd |
oncellchange, onpaste, onbulkchange, onrowaction, onsourcereplaced |
bind:this |
| Angular | [rowAdd] |
the element’s own: (opengrid-paste) and so on |
#grid="opengrid" |
A pivot’s drill-down is the same two things — the attribute and
the event: drillDown with onDrillDown in React, drill-down with @drill-down in Vue,
drillDown with ondrilldown in Svelte, and in Angular the element’s own drill-down and
(opengrid-drill-down). The grid that shows the rows takes the event’s filter as its
filter — a string of JSON, as the attribute is: filter={JSON.stringify(detail.query.filter)}.
const ACTIONS = [{ id: "remove", label: "Remove" }];
function Rooms({ provider }) {
const grid = useRef(null);
return (
<OpengridGrid
ref={grid}
label="Rooms"
datasource="rooms"
rowKey="id"
addRow
provider={provider}
actions={ACTIONS}
onRowAdd={async ({ values }) => ({ state: "saved", key: (await save(values)).id })}
onCellChange={async ({ key, column, value }) => {
await update(key, column, value);
grid.current.setCellState(key, column, "saved");
}}
onRowAction={({ id, key }) => id === "remove" && remove(key)}
/>
);
}
actions and validators follow rule 1: keep them stable, as ACTIONS is here.
React
import { useMemo, useState } from "react";
import { createRestProvider } from "@casoon/opengrid";
import { OpengridGrid } from "@casoon/opengrid/react";
export function Orders() {
const provider = useMemo(
() => createRestProvider({ url: "https://example.org", source: "orders", token: "…" }),
[],
);
const [view, setView] = useState(null);
return (
<OpengridGrid
label="Orders"
datasource="orders"
columns="id,customer,amount"
selection
toolbar
provider={provider}
view={view}
onViewChange={setView}
onSelectionChange={(detail) => console.log(detail.count)}
/>
);
}
refis the element, carrying the connection’ssetCellStateandsetValues.id,className,style,aria-*go to it;classNameis written asclass, which React 18 would not do for a custom element.- The module says
"use client", so a Server Component can import it as a client boundary. hidden,inertandautoFocusfollow the present-or-absent rule too: React 18 would writehidden="false"— hidden — and React 19 sets them as properties.- Exporting: with
const grid = useRef(null)andref={grid}, a handler exports the view asexportRows(provider, loader.module.get_query(grid.current))— see Exporting.
Vue
<script setup>
import { ref } from "vue";
import { createRestProvider } from "@casoon/opengrid";
import { OpengridGrid } from "@casoon/opengrid/vue";
const provider = createRestProvider({ url: "https://example.org", source: "orders", token: "…" });
const view = ref(null);
</script>
<template>
<OpengridGrid
v-model:view="view"
label="Orders"
datasource="orders"
columns="id,customer,amount"
selection
:provider="provider"
@selection-change="(detail) => console.log(detail.count)"
/>
</template>
- The element is rendered by the adapter, so no
compilerOptions.isCustomElementis needed. A page that writes<opengrid-grid>into a template itself needs it. - The events are
update:view,selectionChange,cellChange,paste,bulkChange,rowActionandsourceReplaced—@selection-changeand so on in a template.onRowAddis a prop, because it answers; a template writes it as@row-addall the same. - Under
<KeepAlive>the connection stays; the grid comes back with its view and selection. - A provider held in
ref()is a proxy. That is fine for the providers@casoon/opengridcreates; a provider class of your own with#privatefields belongs inmarkRaworshallowRef. - Exporting: a template ref is the component, and its
$elthe element — withconst grid = ref(null)andref="grid", a handler exports the view asexportRows(provider, loader.module.get_query(grid.value.$el))— see Exporting.
Svelte
<script>
import { createRestProvider } from "@casoon/opengrid";
import { OpengridGrid } from "@casoon/opengrid/svelte";
const provider = createRestProvider({ url: "https://example.org", source: "orders", token: "…" });
let view = $state(null);
let grid = $state();
</script>
<OpengridGrid
label="Orders"
datasource="orders"
columns="id,customer,amount"
selection
{provider}
bind:view
bind:element={grid}
onselectionchange={(detail) => console.log(detail.count)}
/>
- The package ships
.sveltesources; the page’s bundler compiles them with its Svelte plugin, as for any Svelte library. - The callbacks are spelled the Svelte way:
onviewchange,onselectionchange,oncellchange,onrowadd,onpaste,onbulkchange,onrowaction,onsourcereplaced. - Controlled is
bind:view. Aview={…}withoutbind:follows Svelte’s rule for bindable props: after the reader’s first change the component holds its own value, as an<input value>withoutbind:does. - A change inside a
$stateobject —texts.loading = "…"— reaches the grid as much as a new object does. - Exporting:
bind:element={grid}is the element, so a handler exports the view asexportRows(provider, loader.module.get_query(grid))— see Exporting.
Without an adapter
Angular
Angular binds custom elements natively once the component declares CUSTOM_ELEMENTS_SCHEMA.
What it cannot do by itself is call connect; a directive does that. This one runs in
examples/angular — Angular 22, AOT, zoneless — and the tests check that the file there is
this text. It needs Angular 19 or later (standalone by default):
import {
Directive,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
afterNextRender,
inject,
} from "@angular/core";
import {
connect,
type CellWrites,
type Connection,
type ConnectOptions,
type View,
} from "@casoon/opengrid";
@Directive({ selector: "[opengrid]", exportAs: "opengrid" })
export class OpengridDirective implements OnChanges, OnDestroy, CellWrites {
@Input() provider?: ConnectOptions["provider"];
@Input() texts?: ConnectOptions["texts"];
@Input() presentation?: ConnectOptions["presentation"];
@Input() view?: ConnectOptions["view"];
@Output() viewChange = new EventEmitter<View>();
// Editing (the grid only). The answer to a new row is the function's return
// value, which an @Output cannot give.
@Input() rowKey?: string;
@Input() addRow?: boolean;
@Input() actions?: ConnectOptions["actions"];
@Input() validators?: ConnectOptions["validators"];
@Input() rowAdd?: ConnectOptions["onRowAdd"];
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private connection?: Connection;
constructor() {
// In the browser only, once rendered — never during server rendering.
afterNextRender(() => {
this.connection = connect(this.host.nativeElement, {
provider: this.provider,
texts: this.texts,
presentation: this.presentation,
view: this.view,
actions: this.actions,
validators: this.validators,
onViewChange: (view) => this.viewChange.emit(view),
onRowAdd: (detail) => this.rowAdd?.(detail),
});
});
}
// Only the inputs that changed: an unchanged `view` passed along with new
// texts would put back a view the reader has changed since.
ngOnChanges(changes: SimpleChanges): void {
const element = this.host.nativeElement;
if ("rowKey" in changes) {
if (this.rowKey) element.setAttribute("row-key", this.rowKey);
else element.removeAttribute("row-key");
}
if ("addRow" in changes) {
if (this.addRow) element.setAttribute("add-row", "");
else element.removeAttribute("add-row");
}
const changed: ConnectOptions = {};
if ("provider" in changes) changed.provider = this.provider;
if ("texts" in changes) changed.texts = this.texts;
if ("presentation" in changes) changed.presentation = this.presentation;
if ("view" in changes) changed.view = this.view;
if ("actions" in changes) changed.actions = this.actions;
if ("validators" in changes) changed.validators = this.validators;
this.connection?.update(changed);
}
/** What became of an edit the page saves — `#grid="opengrid"` in the template. */
setCellState(...args: Parameters<CellWrites["setCellState"]>): void {
this.connection?.setCellState(...args);
}
setValues(values: Parameters<CellWrites["setValues"]>[0]): void {
this.connection?.setValues(values);
}
ngOnDestroy(): void {
this.connection?.disconnect();
}
}
<opengrid-grid opengrid label="Orders" datasource="orders" columns="id,customer,amount"
[provider]="provider" [(view)]="view"
(opengrid-selection-change)="onSelection($event)"></opengrid-grid>
-
The element’s own events are bound in the template as they are:
(opengrid-selection-change),(opengrid-cell-change). WithstrictTemplatesthe handler takes anEventand reads(event as CustomEvent<SelectionChangeDetail>).detail. -
ngOnChangespasses on only the inputs that changed. Passingviewalong with every other change would put back a view the reader had changed since. -
afterNextRenderconnects in the browser only, after the first render —ngAfterViewInitwould also run during Angular’s server rendering, where there is no element to supply. -
The view is controlled through
[(view)]. With[view]alone the page sets the view when its value changes, and in between the reader’s change stays — the directive does not write the page’s view back. -
Add inputs for
formats,choicesanddefaultViewthe same way when a page needs them. -
Editing:
rowKeyandaddRoware inputs that set the attributes — a plainrow-key="id"works too. The new row is answered by[rowAdd], a function whose return value is the row’s state, since an@Outputcannot answer.exportAs: "opengrid"gives the template the directive —#rooms="opengrid"— and with itsetCellStateandsetValues:<opengrid-grid opengrid #rooms="opengrid" label="Rooms" datasource="rooms" rowKey="id" [addRow]="true" [provider]="provider" [actions]="actions" [rowAdd]="answerRowAdd" (opengrid-cell-change)="onChange($event, rooms)"></opengrid-grid> -
Exporting: a template variable on the element is the element —
#gridon<opengrid-grid>and(click)="exportView(grid)"— and the component’sexportView(grid: HTMLElement)exports it asexportRows(this.provider, query)whenconst query = (await loadOpengrid()).module?.get_query(grid)gives one — see Exporting.
Astro
An element in the markup and connect in a <script> — no island, no framework runtime:
<opengrid-grid label="Orders" datasource="orders" columns="id,customer,amount"></opengrid-grid>
<script>
import { connect, createRestProvider } from "@casoon/opengrid";
connect(document.querySelector("opengrid-grid"), {
provider: createRestProvider({ url: "https://example.org", source: "orders" }),
});
</script>
Using a React, Vue or Svelte adapter inside an Astro island works as it does in that framework, with one catch: an island receives its props serialized, and a provider or a format function does not survive that. Create them inside the island’s own component instead.
Exporting is the same script’s: exportRows(provider, loader.module.get_query(grid)) with
grid = document.querySelector("opengrid-grid") — the whole button is in
Exporting.
Server-rendered pages
ASP.NET Razor, Django, Laravel, plain HTML: the server writes the element with its attributes, and one module script supplies it.
<opengrid-grid label="Orders" datasource="orders" columns="id,customer,amount"></opengrid-grid>
<script type="module">
import { connect, createRestProvider } from "/assets/opengrid/loader.js";
connect(document.querySelector("opengrid-grid"), {
provider: createRestProvider({ url: "/api", source: "orders" }),
texts: { lang: "de", loading: "Wird geladen …" },
});
</script>
An export button is the same module script’s, as in Exporting:
exportRows(provider, loader.module.get_query(grid)), the provider kept in a variable rather
than created inline.
What is tested, and what is not
Each adapter has an example in examples/ that the end-to-end suite runs in Chromium — React
in both 18 and 19 — with StrictMode or a development build, axe-core, and a check that the grid
is collected once the framework takes it out. Each adapter also renders on the server, and the
packed tarball is put into a scratch project’s node_modules — beside the framework and
nothing else from the repository — and each subpath rendered and type-checked there. The same
tarball installs into a project without any framework, which must pull none of them in. Each
example also has a grid of rooms that adds a row, pastes, undoes and runs a row action through
props and callbacks alone, and marks a saved edit through the ref
(tests/e2e/adapters-editing.spec.js, Angular included). The Angular directive
runs the same browser checks in an Angular 22 app built by the Angular CLI, plus a one-way
[view] that keeps the reader’s change.
Hydration is tested in all three (tests/e2e/hydration.spec.js): the HTML the example’s
ssr.mjs renders is put into a page before the element module has loaded, and the framework
hydrates it with the same props. React 18 and 19 and Vue say nothing, keep the server’s element,
and the grid fills with one query and answers the reader; a changed attribute makes React and
Vue warn, and the test fail. Svelte 5 does not compare attributes when it hydrates — by design —
so there the test holds only that the element is kept and connected.
Not tested: React 18 rendering on the server (in the repository’s Node, react-dom@18 resolves
React 19; the React 18 test hydrates React 19’s HTML, which is the same markup), React 19.2’s
<Activity>, the types against @types/react 18, SvelteKit, Nuxt, Next.js and Astro as real
applications.