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;
Opt-in typed props in TypeScript
Section titled “Opt-in typed props in TypeScript”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
Section titled “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 ↗
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'}Custom attribute conversion
Section titled “Custom attribute conversion”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.
Handling non-serializable data types
Section titled “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 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.
Unobserved properties
Section titled “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):
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.
Alternatives
Section titled “Alternatives”The current alternatives are using what HTMLElement provides out-of-the-box, which are:
HTMLElement.datasetfor attributes prefixed withdata-*. Read more about this on MDN.- Methods for reading/writing attribute values:
setAttribute(...)andgetAttribute(...); note that managing the attribute names as strings can be difficult as the code grows.