Documentation menu

Model and update

The model is your component's state. Update is how it changes: a record of reducers, one per message tag, each a pure function from the current state and a message to the next state. Nothing else changes state, so every change is visible to tests and to devtools.

// src/todo.ts
import { define, each, html, intentsOf, type TemplateResult } from '@gyral/core';

export interface Todo {
  readonly id: number;
  readonly text: string;
  readonly done: boolean;
}

export interface State {
  readonly todos: readonly Todo[];
  readonly nextId: number;
}

export type Msg =
  | { readonly _tag: 'Add'; readonly text: string }
  | { readonly _tag: 'Toggle'; readonly id: number }
  | { readonly _tag: 'ClearDone' };

const i = intentsOf<typeof TodoList>();

const Item = (t: Todo): TemplateResult =>
  html`<li>
    <button type="button" value=${t.id} aria-pressed=${t.done} data-intent=${i.Toggle}>
      ${t.text}
    </button>
  </li>`;

export const TodoList = define<State, Msg>()('my-todo-list', {
  init: () => ({ todos: [], nextId: 1 }),
  intent: {
    Add: ({ formData }) => {
      const text = formData?.get('text');
      return typeof text === 'string' && text.trim() !== ''
        ? { _tag: 'Add', text: text.trim() }
        : undefined;
    },
    Toggle: ({ value }) => ({ _tag: 'Toggle', id: Number(value) }),
    ClearDone: () => ({ _tag: 'ClearDone' }),
  },
  update: {
    Add: (s, m) => ({
      todos: [...s.todos, { id: s.nextId, text: m.text, done: false }],
      nextId: s.nextId + 1,
    }),
    Toggle: (s, m) => ({
      ...s,
      todos: s.todos.map((t) => (t.id === m.id ? { ...t, done: !t.done } : t)),
    }),
    ClearDone: (s) => ({ ...s, todos: s.todos.filter((t) => !t.done) }),
  },
  view: (s, i) => html`
    <form data-intent=${i.Add}>
      <label for="text">New todo</label>
      <input id="text" name="text" required />
      <button>Add</button>
    </form>
    <ul>
      ${each(s.todos, (t) => t.id, Item)}
    </ul>
    <button type="button" data-intent=${i.ClearDone}>Clear done</button>
  `,
});

State is plain data

Keep state a record of JSON values: strings, numbers, booleans, null, arrays and plain objects. No Date, Map or class instances. Three things depend on it:

  • Server rendering writes each component's state into the page as JSON, and the browser resumes from it. A Date would come back as a string. In development, the server warns when a seed won't survive the trip.
  • Devtools and tests can print and compare state.
  • Reducers return new objects. State is never mutated, so "did it change?" is a reference check.

Model what can happen with unions rather than flags. { _tag: 'Loading' } | { _tag: 'Found'; repos } | { _tag: 'Failed'; message } can't be loading and failed at once; two booleans can.

The module's i is intentsOf<typeof TodoList>(): the same typed intent names the view receives, as a module constant, so the list's Item row can name an intent and stay a pure function of its todo (Lists). The row's return type is written out, and the view uses its own i, so the row and the component don't infer each other's types. aria-pressed=${t.done} writes "true" or "false", the way ARIA expects.

One reducer per message

update is keyed by message tag, and its type requires a reducer for every tag in Msg. Add a message and the compiler shows you every component that has to handle it.

A reducer receives the state, the message (narrowed to its variant, so m.id is typed) and the context, { props, read }: the component's props and a reader for shared stores. Reducers are pure: no fetch, no Date.now(), no Math.random(), no DOM.

A reducer written outside the spec, to share it or test it alone, is typed with Reducer and Variant from @gyral/core (type-only exports). Variant<Msg, 'Add'> is the member of Msg tagged 'Add':

// src/shelf.ts
import { define, html, type Reducer, type Variant } from '@gyral/core';

interface State {
  readonly books: readonly string[];
}
type Msg = { readonly _tag: 'Add'; readonly title: string } | { readonly _tag: 'Clear' };

export const add: Reducer<State, Msg, Variant<Msg, 'Add'>> = (s, m) => ({
  books: [...s.books, m.title],
});

export const Shelf = define<State, Msg>()('my-shelf', {
  init: () => ({ books: [] }),
  intent: { Add: ({ value }) => ({ _tag: 'Add', title: value ?? '' }), Clear: true },
  update: { Add: add, Clear: () => ({ books: [] }) },
  view: (s, i) => html`
    <button type="button" value="Middlemarch" data-intent=${i.Add}>Add Middlemarch</button>
    <button type="button" data-intent=${i.Clear}>Clear</button>
    <output>${s.books.join(', ')}</output>
  `,
});

Returning commands

To do anything outside the model, a reducer returns a tuple: the next state and a list of commands. A command is a description of a side effect, run by a driver. The answer comes back as another message.

// src/save-button.ts
import { define, html } from '@gyral/core';
import { delay } from '@gyral/time';

export interface State {
  readonly saving: boolean;
}

export type Msg = { readonly _tag: 'Save' } | { readonly _tag: 'Saved' };

export const SaveButton = define<State, Msg>()('my-save-button', {
  init: () => ({ saving: false }),
  intent: { Save: true },
  update: {
    // State plus a command: answer `Saved` after half a second.
    Save: (s) => (s.saving ? s : [{ saving: true }, [delay<Msg>(500, { _tag: 'Saved' })]]),
    Saved: () => ({ saving: false }),
  },
  view: (s, i) => html`
    <button type="button" data-intent=${i.Save} ?disabled=${s.saving}>
      ${s.saving ? 'Saving…' : 'Save'}
    </button>
  `,
});

The return type is Next<State, Msg>: either a state, or [state, commands]. State is never an array, so the two can't be confused. init can return commands too, to load data when the component appears. Save: true is the short form of a parser that only sends its tag; it works for messages with no other fields. Effects and drivers covers commands in depth.

Subscriptions

Work that should keep running while some condition holds (a clock, polling, a socket, a store or media-query watch) doesn't start in a reducer. subscriptions(state, props) returns the commands that should be running now, and Gyral keeps the running set in step with it:

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

export interface State {
  readonly running: boolean;
  readonly now: number;
}

export type Msg = { readonly _tag: 'Toggle' } | { readonly _tag: 'Tick'; readonly now: number };

export const Clock = define<State, Msg>()('my-clock', {
  init: () => ({ running: false, now: 0 }),
  // While running, tick every second; pausing stops the timer, resuming starts it again.
  subscriptions: (s) => (s.running ? [every(1000, (now): Msg => ({ _tag: 'Tick', now }))] : []),
  intent: { Toggle: true },
  update: {
    Toggle: (s) => ({ ...s, running: !s.running }),
    Tick: (s, m) => ({ ...s, now: m.now }),
  },
  view: (s, i) => html`
    <button type="button" data-intent=${i.Toggle}>${s.running ? 'Pause' : 'Start'}</button>
    <p><time>${s.now === 0 ? '—' : new Date(s.now).toLocaleTimeString()}</time></p>
  `,
});

After the first render and after every update and props change, Gyral compares the list with what is running, lane by lane (a command's key, else its driver's name): a new lane starts, a missing lane is stopped, a lane whose input changed restarts, and an unchanged lane keeps running. Inputs are compared as data: functions inside them are ignored, so an inline selector such as (s) => s.unread doesn't restart a lane on every update. To restart when a selector changes, put what it depends on in the key, for example one that includes the room id. Moving the element keeps subscriptions running, removing it stops them, and attaching it again starts them from the current state. So there is no start in init, no restart on PropsChanged and no reconnect handling. every(ms, (now) => msg) sends the time at once and then every ms. In model tests, subscriptionsFor(Clock.spec, state, props) returns the list.

Framework messages

Gyral sends five messages of its own, plus three to form-associated components (FormReset, FormDisabled and FormStateRestore). Their reducers are optional: leave one out and the message changes nothing.

Message Sent when
PropsChanged A declared prop changed after the first render. Has props and prev. Props
IntentRejected Input failed a schema in form() or field(), or the server rejected a form. Forms
StoreChanged A store the component reads changed. Shared state
Hydrated The component is live in the browser, once, after its first render. Has serverRendered.
Errored An update, parser or command of this component failed. Has phase and error (a GyralError). Error handling

Hydrated is the hook for progressive enhancement: render the no-JavaScript version on the server and in the first client render (so hydration matches), then switch to the enhanced UI in the Hydrated reducer.

Starting from messages

Every Gyral element has an initialMessages property. Messages listed there run through update right after init, before the first render, on the server too. The server uses it to render a form with the errors from a rejected submission through the component's own IntentRejected reducer, so errors look the same with or without JavaScript.

State in CSS and in transitions

Two optional spec fields read the model, so the model stays the one source of truth:

  • states: (s) => ({ loading: s.results._tag === 'Loading' }) mirrors boolean facts onto the element's custom states, for CSS: :host(:state(loading)).
  • viewTransition: (prev, next, msg) => boolean renders a state change inside document.startViewTransition, for route changes and list reorders. It is skipped where the browser doesn't support it, or when the user prefers reduced motion.