docs: document unobserved state via plain class properties

This commit is contained in:
ayo 2026-07-21 16:13:41 +02:00 committed by GitHub
parent 975b045a0a
commit cdfe928112
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 133 additions and 4 deletions

View file

@ -201,6 +201,12 @@ See [Life-cycle Hooks](/life-cycle-hooks/) for worked examples. See it live: [Li
Override these to control how one prop crosses the prop/attribute boundary, and
call `super` for the props you do not handle.
The default conversion round-trips values through JSON. Types JSON cannot
restore — `Date`, `Map`, `Set`, `URL`, class instances — need overridden
converters to live on `static props`; see
[Custom attribute conversion](/prop-access/#custom-attribute-conversion) for
worked examples, including the non-serializable cases.
### `toAttribute(name, value)`
Converts a prop value into the attribute value that reflects it.

View file

@ -55,9 +55,14 @@ class FlagBox extends WebComponent {
```
```html
<flag-box></flag-box> <!-- props.flag === false -->
<flag-box flag></flag-box> <!-- props.flag === true -->
<flag-box flag=""></flag-box> <!-- props.flag === true -->
<!-- props.flag === false -->
<flag-box></flag-box>
<!-- props.flag === true -->
<flag-box flag></flag-box>
<!-- props.flag === true -->
<flag-box flag=""></flag-box>
```
Reflection works the same way in reverse — `true` sets the bare attribute,
@ -134,8 +139,8 @@ class EventCard extends WebComponent {
```
```html
<event-card when="2026-07-20"></event-card>
<!-- props.when is a Date -->
<event-card when="2026-07-20"></event-card>
```
Both take the **camelCase prop key**, matching your `static props` declaration
@ -168,6 +173,82 @@ toAttribute(name, value) {
`string` and your parsed `Date` is refused.
</Aside>
#### Non-serializable types
The default converters round-trip prop values through JSON, which restores
numbers, booleans, and plain objects/arrays exactly. Types JSON cannot
represent don't survive the trip: a `Date` comes back as a plain string, and a
`Map` or `Set` collapses to `"{}"` before it ever reaches the attribute.
Such types are still first-class props — declare a real default of that type
(see the caution above) and override the converters. You can hand-write a
textual form per prop, as `EventCard` above does for its `Date`; when several
props need this, delegate to a serializer built for the job instead:
[devalue](https://github.com/sveltejs/devalue) round-trips `Map`, `Set`,
`Date`, `RegExp`, `BigInt`, `undefined`, and even cyclic references. One
generic pair of overrides then covers every structured prop, present and
future — here is `EventCard` rewritten with it, gaining a `Set` of tags along
the way:
```js
import { parse, stringify } from 'devalue'
class EventCard extends WebComponent {
static props = {
when: new Date(0),
tags: new Set(),
title: '',
}
toAttribute(name, value) {
if (value instanceof Object) return stringify(value)
return super.toAttribute(name, value)
}
fromAttribute(name, value) {
if (this.constructor.props[name] instanceof Object) return parse(value)
return super.fromAttribute(name, value)
}
}
```
The two guards are asymmetric on purpose: `toAttribute` sees the live value,
but `fromAttribute` only ever sees a string — so it consults the declared
default to decide whether the attribute is devalue-encoded. `title` misses
both guards and reflects as a plain string, exactly as before.
The encoded attribute is not as hand-friendly as a bespoke `when="2026-07-20"`,
but it is still plain text — write it in markup with single quotes, since the
payload contains double quotes:
```html
<!-- props.when is a real Date, props.tags a real Set -->
<event-card
when='[["Date","2026-07-20T09:30:00.000Z"]]'
tags='[["Set",1,2],"alpha","bravo"]'
></event-card>
```
devalue is your component's dependency, not wcb's — the base class stays
zero-dependency.
<Aside type="caution" title="Reassign, don't mutate">
The props proxy only sees *assignments*. `this.props.tags.add('x')` mutates
the Set behind the proxy's back — no reflection, no render, and the attribute
goes stale. Assign a fresh instance instead: `this.props.tags = new
Set(this.props.tags).add('x')`.
</Aside>
Converters cover any value with a sensible **textual form** — bespoke like an
ISO date, or generic like devalue's encoding. A value with no textual form at
all — a function, an element reference, a live handle like an
`AbortController` — is not a converter problem: even devalue refuses it
(`Cannot stringify a function`), and it doesn't belong in `static props` in
the first place. Keep it as a plain class property instead — see
[Unobserved properties](#unobserved-properties) below. wcb warns once per
class when a declared default is a `function` or `symbol` for exactly this
reason.
### Typed props in TypeScript
See it live: [Compile-time prop types demo ↗](https://demo.webcomponent.io/examples/typed-props/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/)
@ -200,6 +281,48 @@ This is types-only — the runtime is unchanged, and `static strictProps` still
Values in the defaults object widen as usual, so `variant: 'primary'` types as `string`. To narrow it to a union of allowed values, annotate the defaults: `const props = { variant: 'primary' as 'primary' | 'ghost' }`.
</Aside>
### Unobserved properties
Everything declared in `static props` is observed and reflected: each key gets
an attribute, writes trigger `render()` and `onChanges()`, and the default
shows up in the DOM on first connect. That is the right contract for a
component's public, DOM-facing API — and unnecessary for internal state.
For state that doesn't belong in the DOM, use a plain class property. A
`WebComponent` is still just a class extending `HTMLElement`, so ordinary
properties work exactly as on any element and are invisible to the props
machinery — no attribute, no observation, no automatic render:
```js
class DataTable extends WebComponent {
static props = { compact: false } // public API: <data-table compact>
rows = [] // internal state — never becomes an attribute
#controller = new AbortController() // non-serializable values are fine here
async onInit() {
const res = await fetch('/rows', { signal: this.#controller.signal })
this.rows = await res.json()
this.render() // unobserved changes render when you say so
}
onDestroy() {
this.#controller.abort()
}
}
```
Because nothing watches a plain property, call
[`this.render()`](/template-vs-render/) yourself when a change to one should
update the view.
The rule of thumb: `static props` is for values a consumer sets from markup or
styles against with attribute selectors; a class property — public or
`#private` — is for everything else: large data, functions, and handles like
timers or `AbortController`s that could never round-trip through an attribute
anyway. If you catch yourself thinking "I don't want this showing up as an
attribute", that is the signal it should be a class property, not a prop.
### Alternatives
The current alternatives are using what `HTMLElement` provides out-of-the-box, which are: