2 properties and attributes
GOAL
get a JavaScript object from a React page into a custom element intact, then make the same element work for a host that can only hand it text.
CONCEPT
an HTML attribute holds one kind of value - text. A DOM property holds any value at all. React 19 sets a JSX prop as a property when the element already has one by that name, and as an attribute otherwise, where setAttribute runs String() over whatever you passed: an object turns into the literal text "[object Object]" and the data is gone. React 18 and earlier set everything as attributes, which is why custom elements had a reputation for not working in React.
HINT
the property path keeps the object you passed - the same one, not a copy. Text cannot carry a reference, so the attribute path has to serialise on the way out and parse on the way in, and what comes back is a different object with equal contents.
MIRRORS
a design-system widget shipped as a custom element and handed a config object by a React page; a chart element given its rows the same way.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 10-embedded-mods/02-props-and-attributes.tsxSource
// DOJO · Module 10 / Exercise 2 — properties and attributes
// GOAL: get a JavaScript object from a React page into a custom
// element intact, then make the same element work for a host
// that can only hand it text.
// CONCEPT: an HTML attribute holds one kind of value - text. A DOM
// property holds any value at all. React 19 sets a JSX prop as
// a property when the element already has one by that name,
// and as an attribute otherwise, where setAttribute runs
// String() over whatever you passed: an object turns into the
// literal text "[object Object]" and the data is gone. React 18
// and earlier set everything as attributes, which is why custom
// elements had a reputation for not working in React.
// HINT: the property path keeps the object you passed - the same one,
// not a copy. Text cannot carry a reference, so the attribute
// path has to serialise on the way out and parse on the way in,
// and what comes back is a different object with equal
// contents.
// MIRRORS: a design-system widget shipped as a custom element and
// handed a config object by a React page; a chart element given
// its rows the same way.
// Run: pnpm koan 10-embedded-mods/02-props-and-attributes.tsx
import type { HTMLAttributes } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
afterEach(cleanup);
type Settings = { theme: string; flags: string[] };
const SETTINGS: Settings = { theme: "dark", flags: ["beta", "sound"] };
// The element somebody else shipped. It renders whichever source it
// was given: the property if the host could set one, otherwise the
// attribute. Nothing in this class is broken.
class SettingsPanel extends HTMLElement {
// A declared property is what makes React choose the property path:
// it only sets a property that already exists on the instance.
settings?: Settings;
connectedCallback(): void {
const settings = this.settings ?? this.settingsFromAttribute();
this.textContent = settings ? settings.flags.join(",") : "(no settings)";
}
// --- TODO 2 ----------------------------------------------------------
// Some hosts cannot set a property - a plain HTML page, a template
// language, a framework that only writes attributes. For those, the
// object has to travel as text. Read the data-settings attribute and
// turn it back into a Settings object. Return null when there is no
// attribute at all.
settingsFromAttribute(): Settings | null {
return null;
}
}
customElements.define("settings-panel", SettingsPanel);
// Given: TypeScript has never heard of <settings-panel>, so the tag
// and the props a host may write are declared here.
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"settings-panel": HTMLAttributes<HTMLElement> & {
settings?: Settings;
"data-settings"?: string;
};
}
}
}
// --- TODO 1 ------------------------------------------------------------
// This host can set a property, so the object should cross untouched.
// It does not pass anything at all yet. Hand SETTINGS to the element on
// the prop whose name matches the declared property above.
function PropertyHost() {
return <settings-panel data-testid="panel" />;
}
// --- TODO 2 (host half) ------------------------------------------------
// This host writes attributes only. Send SETTINGS through data-settings
// as text that TODO 2's reader can parse back. Passing the object
// itself is what produces "[object Object]".
function AttributeHost() {
return <settings-panel data-testid="panel" data-settings="" />;
}
describe("Module 10 / Exercise 2 — properties and attributes", () => {
it("TODO 1 — a property carries the object itself", () => {
const { container } = render(<PropertyHost />);
const panel = container.querySelector("settings-panel") as SettingsPanel;
expect(
panel.textContent,
"TODO 1 — the panel should list the flags it was handed",
).toBe("beta,sound");
expect(
panel.settings,
"TODO 1 — a property carries the very object, not a copy",
).toBe(SETTINGS);
});
it("TODO 2 — an attribute carries text, so it carries a copy", () => {
const { container } = render(<AttributeHost />);
const panel = container.querySelector("settings-panel") as SettingsPanel;
expect(
panel.textContent,
"TODO 2 — the panel should list the same flags, read from text",
).toBe("beta,sound");
const parsed = panel.settingsFromAttribute();
expect(
parsed,
"TODO 2 — the contents survive the trip through text",
).toEqual(SETTINGS);
expect(
parsed,
"TODO 2 — but text cannot carry a reference, so this is a copy",
).not.toBe(SETTINGS);
// Given: what the attribute would have held if the object had been
// handed over raw. This is the failure the serialisation avoids.
expect(String(SETTINGS)).toBe("[object Object]");
});
});