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;
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 you
can narrow them to the values you accept:
const props = { ariaChecked: 'false' as '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)}Non-serializable types
Section titled “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 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:
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:
<!-- 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.
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.
Typed props in TypeScript
Section titled “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 (the type above, the defaults within):
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>` }}Unions stay narrow with nothing to cast, and the annotation cuts both ways: the defaults themselves are checked against the type, so a missing key or a default outside the union is a compile error too.
This is types-only. The runtime is unchanged, and static strictProps still guards writes that come in from attributes at runtime. Omitting the type argument keeps the previous untyped behavior.
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.