336 lines
12 KiB
Text
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.
|