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
Datewould 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) => booleanrenders a state change insidedocument.startViewTransition, for route changes and list reorders. It is skipped where the browser doesn't support it, or when the user prefers reduced motion.