A custom element <user-card> needs to receive an array of user objects from the surrounding page. Why can that array not be passed as an HTML attribute, and what is the standard way to hand it over?
answer
- markup channel versus JavaScript channel
- attribute values have exactly one type
- objects stringify to something useless
- reflection keeps primitives in sync
- input.value is deliberately not reflected
basics
~20 sHTML attributes can hold only strings, so an array survives as an attribute only if you serialize it to text and parse it back. Pass rich data as a JavaScript property instead — el.users = [...] — which hands over the real object by reference.
solid answer
~40 sAttributes live in the markup and their values are always strings, so `<user-card users="[object Object]">` is all you get from an object. Rich data therefore travels as a **property**: the element exposes `get users()` / `set users(value)` and a consumer writes `card.users = [{ id: 1 }]`, which passes the array by reference with no parsing. The convention is a split by data type: strings, numbers and booleans go through attributes so the component is configurable from plain HTML and from a server-rendered page, and objects, arrays and functions go through properties. Elements usually *reflect* the primitive ones — the setter calls `setAttribute` and the getter reads `getAttribute` — so markup and JavaScript stay in sync. Never reflect an object back to an attribute; JSON in the DOM is expensive and immediately stale.
code
javascript · 20 linesclass UserCard extends HTMLElement {
#users = [];
get users() { return this.#users; }
set users(value) { this.#users = value; this.render(); }
get label() { return this.getAttribute('label') ?? ''; }
set label(value) { this.setAttribute('label', value); }
render() {
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
root.textContent = `${this.label}: ${this.#users.length}`;
}
}
customElements.define('user-card', UserCard);
const card = document.createElement('user-card');
card.label = 'Team';
card.users = [{ id: 1, name: 'Ada' }];
document.body.append(card);go deeper
Be able to say plainly that attribute values are strings and that objects or arrays must be set as JavaScript properties on the element.
Explain reflection — a setter writing setAttribute and a getter reading it — and justify the split where primitives go through attributes and rich data goes through properties.
Show that you can diagnose a data-not-arriving bug by checking which channel the consumer used, and argue when a JSON attribute is an acceptable boot-time input rather than an update path.
Own the API contract for a design system: which inputs are markup-configurable and server-renderable, which are property-only, and what that decision costs consumers whose template layer can set only attributes.
## Two different channels A DOM element carries two parallel sets of state. **Content attributes** are what appears in the markup: `getAttribute`, `setAttribute`, `hasAttribute`, `removeAttribute`, and whatever the HTML parser found in the source. **IDL properties** are JavaScript properties on the element object: `el.value`, `el.checked`, `el.users`. They are not the same thing, and only some of them are wired together. The hard constraint is that an attribute value is a *string*. The DOM stores it as a string, HTML serializes it as a string, and the parser produces a string. Assigning an object goes through `String(value)`, so `card.setAttribute('users', [{id: 1}])` stores `"[object Object]"` and the data is gone. ## Which channel for which data The convention the platform's own elements follow: - **Strings, numbers, booleans → attributes.** They serialize losslessly, they can be written by hand in HTML, they can be produced by a server, and they show up in DevTools. Booleans use presence rather than value: `<input disabled>` — the attribute exists or it does not, which is why `disabled="false"` is still disabled. - **Objects, arrays, functions, DOM nodes → properties.** There is no honest string form, so the consumer sets a property and the element stores the reference. ```js class UserCard extends HTMLElement { #users = []; get users() { return this.#users; } set users(value) { this.#users = value; this.render(); } render() { /* build the shadow root from this.#users */ } } ``` ## Reflection For the primitives, elements usually keep the two channels in sync — this is called **reflection**. The property getter reads the attribute and the setter writes it: ```js get label() { return this.getAttribute('label') ?? ''; } set label(v) { this.setAttribute('label', v); } ``` Reflection is why `img.src` and `<img src>` agree. It also means the attribute-changed reaction hook is the single place where the element responds to a change, whichever channel was used. Reflection is deliberately one-directional in some built-ins — `input.value` is famously *not* reflected, because `value` is live user state and the `value` attribute is only the initial value. That asymmetry is a good model: reflect configuration, do not reflect live state, and never reflect an object. ## Why this bites in framework interop A declarative template system that renders `<user-card users={list}>` may do one of two things: call `setAttribute('users', String(list))`, or assign `el.users = list`. Template systems that only ever set attributes cannot pass an array at all — the usual workaround there is `JSON.stringify` on the way in and `JSON.parse` in the element, which costs a serialization round-trip on every update and loses functions, `Date`s and identity. Systems that set properties when the element has a matching property work naturally, and this is why a well-designed element should define a real accessor for every rich input rather than relying on attributes. The cheap diagnostic when data does not arrive: open DevTools, inspect the element, and look at both. If the Elements panel shows `users="[object Object]"`, the consumer is going through the attribute channel. If the attribute is absent but `$0.users` in the console shows the array, the property arrived and the bug is inside the element's rendering. ## The JSON-attribute escape hatch Sometimes you genuinely need a rich value in markup — a server renders the page and there is no script to set a property yet. Then a JSON attribute is legitimate: ```html <user-card users='[{"id":1,"name":"Ada"}]'></user-card> ``` Parse it once when the attribute changes, keep the parsed value in a private field, and let the property setter bypass the attribute entirely. Treat it as a boot-time input, not the update path. ## The rule to state in an interview Attributes are the *markup* API: strings, configuration, server-renderable, inspectable. Properties are the *JavaScript* API: any type, cheap, by reference. A good custom element exposes both for primitives and keeps them in sync; for anything richer than a primitive, the property is the only correct channel.
- If the element only ever needs booleans and strings, is there any reason to define properties at all?Yes — ergonomics and interop. A property gives consumers `el.disabled = true` instead of `setAttribute`/`removeAttribute` gymnastics, gives type coercion one home, and lets template systems that prefer properties bind naturally. It is a few lines of accessor per primitive and it makes the element behave like a built-in.
- Why is `disabled="false"` still disabled on a boolean attribute?Boolean content attributes are read by *presence*, not by value. The HTML rules say the attribute being present means true regardless of its string value, so `"false"`, `""` and `"disabled"` are all true. To turn it off you must remove the attribute — which is exactly what a reflecting setter should do for a false value.
- What goes wrong if you reflect an array back into an attribute on every change?You pay a `JSON.stringify` on every update, you bloat the serialized HTML, you lose anything JSON cannot express, and you create two sources of truth that drift the moment someone mutates the array in place. Reflect configuration, never live or structured state.
saying these in an interview costs you the question
- Claiming attributes can hold objects directly
- Thinking getAttribute returns the original typed value
- Reflecting arrays into JSON attributes on every render
- Believing disabled="false" disables the boolean
- Assuming el.value always mirrors the value attribute