Skip to content

Prop Access

The props property of the WebComponent interface is provided for easy read/write access to a camelCase counterpart of any observed attribute.

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;

See it live: Compile-time prop types demo ↗ and Typed props demo ↗

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:

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.

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 ↗

class FlagBox extends WebComponent {
static props = { flag: false }
}
<!-- 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:

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:

el.toggleAttribute('flag', true) // prop syncs, component re-renders
:host([flag]) {
/* matches only when the prop is actually true */
}

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:

type ToggleProps = {
ariaChecked: 'true' | 'false'
}

See it live: Custom attribute converters demo ↗

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:

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)
}
}
<!-- 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:

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

Section titled “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.

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 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:

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:

<!-- 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>

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 below. wcb warns once per class when a declared default is a function or symbol for exactly this reason.

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):

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() 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 AbortControllers 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.

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.
  2. Methods for reading/writing attribute values: setAttribute(...) and getAttribute(...); note that managing the attribute names as strings can be difficult as the code grows.