Documentation menu

Getting started

In this guide you'll create a Gyral app, write a counter component, put it on a page, and test it. You need Node.js 24 or later and a package manager. The examples use npm; pnpm and yarn work the same way.

Create a project

The quickest start is create-gyral. It sets up a Vite project with Gyral, TypeScript and a first test:

npm create gyral@latest my-app -- --template ssr
cd my-app
npm install
npm run dev

There are two templates:

  • basic: a client-rendered app. The page loads and your components render in the browser. It builds client-only, without hydration code; remove clientOnly: true from vite.config.ts before you render any page on a server.
  • ssr: the same component rendered on the server first, so the page works before any JavaScript loads, then hydrated in place.

Leave out --template and it asks you which one you want.

Or set it up by hand

Gyral works with any bundler (see which build of core each one gets). To add it to a Vite project yourself:

npm create vite@latest my-app -- --template vanilla-ts
cd my-app
npm install @gyral/core

@gyral/core is the whole runtime: Gyral renders with its own view layer and has no runtime dependencies.

Then add Gyral's Vite plugins, gyral(). They add the template compiler to vite build: every template is checked and precompiled, so a mistake fails the build with a code frame and the bundle doesn't carry the code that prepares templates at runtime:

// vite.config.ts
import { defineConfig } from 'vite';
import { gyral } from '@gyral/core/vite';

export default defineConfig({
  plugins: [gyral()],
});

List plugins of your own beside it: plugins: [gyral(), mine()].

To see the same checks in your editor, add the ESLint config from @gyral/core/eslint (see Views). Optionally, npm install -D parse5 lets the compiler check markup against a full HTML parser too.

Write a component

A component is a custom element defined from four parts: its initial state, the intents it understands, how each message changes the state, and how the state looks.

// src/counter.ts
import { define, html } from '@gyral/core';

export interface State {
  readonly count: number;
}

export type Msg = { readonly _tag: 'Increment' } | { readonly _tag: 'Decrement' };

export const Counter = define<State, Msg>()('my-counter', {
  init: () => ({ count: 0 }),
  intent: {
    Increment: () => ({ _tag: 'Increment' }),
    Decrement: () => ({ _tag: 'Decrement' }),
  },
  update: {
    Increment: (s) => ({ count: s.count + 1 }),
    Decrement: (s) => ({ count: s.count - 1 }),
  },
  view: (s, i) => html`
    <button type="button" data-intent=${i.Decrement}>Decrement</button>
    <output aria-live="polite">${s.count}</output>
    <button type="button" data-intent=${i.Increment}>Increment</button>
  `,
});

Read it from the bottom up:

  • The view is an html template. It doesn't attach event handlers. Instead it names an intent on each button with data-intent. i is typed from your messages, so a misspelt i.Incremnt is a compile error.
  • Intent says how a platform event becomes a message. A click on a button carrying data-intent=${i.Increment} runs the Increment parser, which returns { _tag: 'Increment' }. A parser can also read the event's value, form data or key, and return undefined to decline it.
  • Update has one reducer per message, and TypeScript checks you have one for every message. A reducer returns the next state and never changes the old one.
  • Init gives the state the component starts with.

Use it in a page

define() registers the element, so importing the module is enough. Replace the contents of index.html's <body> and import your component:

<body>
  <main>
    <h1>My first Gyral app</h1>
    <my-counter></my-counter>
  </main>
  <script type="module" src="/src/counter.ts"></script>
</body>

Run npm run dev and open the address it prints. The counter is a standard custom element with its own Shadow DOM, so it works in any page and alongside any framework.

When the page loads an entry module instead (the basic template's src/main.ts), that module must import every component module, for its side effect. Each new component in its own file gets a line here, such as the stepper below:

// src/main.ts
import './counter.js';
import './stepper.js';

A module that nothing imports never runs, so its tag is never defined: <my-stepper> stays an empty unknown element. In development, Gyral warns in the console (G0014) when a tag on the page is still undefined 3 seconds after it loaded; production builds stay silent. A component that renders another component in its view imports that module too.

Read input

Intents get more than the event. Here's a step size the user can type. on('input', parser) gives the parser the number input's valueAsNumber (undefined while the field is empty or invalid), and the parser checks it before it reaches the model:

// src/stepper.ts
import { define, html, on } from '@gyral/core';

export interface State {
  readonly count: number;
  readonly step: number;
}

export type Msg =
  { readonly _tag: 'Increment' } | { readonly _tag: 'StepChanged'; readonly step: number };

export const Stepper = define<State, Msg>()('my-stepper', {
  init: () => ({ count: 0, step: 1 }),
  intent: {
    Increment: () => ({ _tag: 'Increment' }),
    // Not a whole number: ignore the keystroke, keep the last good step.
    StepChanged: on('input', ({ valueAsNumber: step }) =>
      step !== undefined && Number.isInteger(step) ? { _tag: 'StepChanged', step } : undefined,
    ),
  },
  update: {
    Increment: (s) => ({ ...s, count: s.count + s.step }),
    StepChanged: (s, m) => ({ ...s, step: m.step }),
  },
  view: (s, i) => html`
    <label for="step">Step</label>
    <input id="step" type="number" value=${s.step} data-intent=${i.StepChanged} />
    <button type="button" data-intent=${i.Increment}>Add ${s.step}</button>
    <output aria-live="polite">${s.count}</output>
  `,
});

The model only ever sees a whole number. Parsing happens once, at the boundary, which is where Gyral wants it.

Test it

update is a plain function, so you can test behaviour without a browser. @gyral/testing gives you step (one message) and run (a sequence):

npm install -D @gyral/testing vitest
// src/counter.test.ts
import { expect, it } from 'vitest';
import { run, step } from '@gyral/testing';
import { Counter } from './counter.js';

it('counts', () => {
  expect(step(Counter.spec, { count: 1 }, { _tag: 'Increment' }).state).toEqual({ count: 2 });

  const { state } = run(Counter.spec, [
    { _tag: 'Increment' },
    { _tag: 'Increment' },
    { _tag: 'Decrement' },
  ]);
  expect(state.count).toBe(1);
});

Counter.spec is the object you passed to define(). Tests that need the real element (clicks, focus, rendering) run in a browser with Vitest browser mode and await settled() before they look at the DOM; @gyral/testing also has helpers for server rendering, hydration and virtual time. See Testing.

Next steps