wcb/docs/src/content/docs/guides/prop-access.mdx

336 lines
12 KiB
Text

---
title: Prop Access
slug: prop-access
---
import { Aside } from '@astrojs/starlight/components'
The `props` property of the `WebComponent` interface is provided for easy read/write access to a camelCase counterpart of _any_ observed attribute.
```js
class HelloWorld extends WebComponent {
static props = {
myProp: 'World',
}
get template() {
return html` <h1>Hello ${this.props.myProp}</h1> `
}
}
```
Assigning a value to the `props.camelCase` counterpart of an observed attribute will trigger an "attribute change" hook.
For example, assigning a value like so:
```
this.props.myName = 'hello'
```
...is like calling the following:
```
this.setAttribute('my-name','hello');
```
Therefore, this will tell the browser that the UI needs a render if the attribute is one of the component's observed attributes we explicitly provided with `static props`;
<Aside type="note">
The `props` property of `WebComponent` works like `HTMLElement.dataset`, except `dataset` is only for attributes prefixed with `data-`. A camelCase counterpart using `props` will give read/write access to any attribute, with or without the `data-` prefix.
Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can hold primitive types 'number', 'boolean', 'object' and 'string'.
</Aside>
### Opt-in 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/)
By default `this.props` is a permissive `{ [name: string]: any }` map. In TypeScript you can get compile-time types on your declared props. Declare the shape as a named type, pass it as the class type argument, and annotate `static props` with it in the initialization:
```ts
type CozyButtonProps = {
variant: 'primary' | 'ghost'
disabled: boolean
}
class CozyButton extends WebComponent<CozyButtonProps> {
static props: CozyButtonProps = {
variant: 'primary',
disabled: false,
}
get template() {
this.props.variant // 'primary' | 'ghost'
this.props.disabled // boolean
this.props.notAProp // ❌ compile error: not declared
this.props.disabled = 'yes' // ❌ compile error: string is not boolean
this.props.variant = 'plaid' // ❌ compile error: not in the union
return html`<button class=${this.props.variant}></button>`
}
}
```
This annotation applies the type-check to succeeding assignments while the defaults themselves are checked against the type, so a missing key or a default outside the union is a compile error too.
<Aside type="tip" title="Use `static strictType` if you want runtime type guards">
At runtime, assigning a different type to a prop quietly fails. Setting [`static strictProps`](/api/web-component/#static-strictprops) to true will throw a `TypeError` when wrong types are assigned.
</Aside>
### Boolean props
Boolean props follow the HTML boolean-attribute convention, exactly like native
`disabled` and `required`: **presence means `true`, absence means `false`.**
See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/)
```js
class FlagBox extends WebComponent {
static props = { flag: false }
}
```
```html
<!-- 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,
`false` removes it entirely:
```js
el.props.flag = true // <flag-box flag>
el.props.flag = false // <flag-box>
```
Because `false` is an _absent_ attribute rather than `flag="false"`, the
platform's own API and CSS presence selectors both behave as you'd expect:
```js
el.toggleAttribute('flag', true) // prop syncs, component re-renders
```
```css
:host([flag]) {
/* matches only when the prop is actually true */
}
```
<Aside type="caution" title="Any present value is true">
Just like native `disabled="false"` is still disabled, **`flag="false"` parses
as `true`**. Presence wins, there is no special case for the literal string.
Writing `setAttribute('flag', String(someBool))` therefore always yields
`true`; use `toggleAttribute('flag', someBool)` instead. wcb logs a
`console.warn` when it sees a boolean attribute written as `"true"` or
`"false"` so this cannot fail silently.
</Aside>
Enumerated attributes like `contenteditable` and `aria-*` attributes are the
exception. They are genuine strings where `"false"` is meaningful, so declare
them as **string** props rather than booleans. Strings serialize literally and
are never removed, so they need nothing special at runtime; in TypeScript,
narrow them to the values you accept in your
[props type](#typed-props-in-typescript):
```ts
type ToggleProps = {
ariaChecked: 'true' | 'false'
}
```
<Aside type="tip" title="Default boolean props to false">
HTML has no true-default boolean attribute: absence has to mean both "false"
and "default", which only works when they coincide. Model an on-by-default
flag with an inverted name (`disabled`, not `enabled`). wcb warns once per
class on a `true` default, and for such a prop, removing the attribute lands
on `false`, not back on the declared default.
</Aside>
### Custom attribute conversion
See it live: [Custom attribute converters demo ↗](https://demo.webcomponent.io/examples/attribute-converters/)
The rules above cover the common cases. When a prop needs its own
serialization (a `Date`, a delimited list, an enumerated attribute where
`"false"` is meaningful) override `toAttribute` and `fromAttribute`, and
delegate everything else to `super`:
```js
class EventCard extends WebComponent {
static props = { when: new Date(0), title: '' }
toAttribute(name, value) {
if (name === 'when') return value.toISOString().slice(0, 10)
return super.toAttribute(name, value)
}
fromAttribute(name, value) {
if (name === 'when') return new Date(`${value}T00:00:00Z`)
return super.fromAttribute(name, value)
}
}
```
```html
<!-- props.when is a Date -->
<event-card when="2026-07-20"></event-card>
```
Both take the **camelCase prop key**, matching your `static props` declaration
and `onChanges`'s `property`, not the kebab-case attribute name.
`toAttribute` returning **`null` removes the attribute**. That is exactly how a
`false` boolean becomes an absent attribute, and it is available to any prop:
```js
toAttribute(name, value) {
// an empty string means "no attribute at all" for this prop
return value === '' ? null : super.toAttribute(name, value)
}
```
#### Conversion is only triggered on attribute changes
When assigning a value to a prop, wcb does **not** check and parse the
attribute back through `fromAttribute`. The prop you assigned is already the
source of truth and the text form attribute could be a less precise
representation. In the example above `props.when` keeps its full
timestamp even though the attribute carries only the date. `render()` and
`onChanges` still fire as normal. `fromAttribute` conversion is only called
for attributes written from *outside* the component via markup, `setAttribute`,
or `toggleAttribute`.
<Aside type="caution" title="The declared type still applies">
A prop's runtime type comes from the `typeof` of its default, and the props
proxy rejects writes that violate it. A `Date`-valued prop therefore needs an
actual `Date` default (`new Date(0)`), not `''`. Otherwise the prop is typed
`string` and your parsed `Date` is refused.
</Aside>
#### Handling non-serializable data 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 `"{}"`.
Such types are still first-class props. You can declare a real default of that type (see the caution above) and override the converters. The `EventCard` example above handles this by using custom conversion logic for its `Date` prop.
Another way to do this is to use a library built for the job instead.
You can opt to use a serializer like [devalue](https://github.com/sveltejs/devalue) as your component's dependency, which can serialize and parse `Map`, `Set`, `Date`, `RegExp`, `BigInt`, `undefined`, and even cyclic references. One
generic pair of overrides then covers every structured prop.
Here is `EventCard` rewritten with it, with an additional prop `tags` of type `Set`:
```js
import { parse, stringify } from 'devalue'
class EventCard extends WebComponent {
static props = {
when: new Date(0),
tags: new Set(),
title: '',
}
toAttribute(name, value) {
// handles `when` and `tags`
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 (using `this.constructor.props`) 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 readable as a bespoke `when="2026-07-20"`,
but it is still plain text:
```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>
```
<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.
### 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:
1. `HTMLElement.dataset` for attributes prefixed with `data-*`. Read more about this [on MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset).
1. Methods for reading/writing attribute values: `setAttribute(...)` and `getAttribute(...)`; note that managing the attribute names as strings can be difficult as the code grows.