Skip to content
HomePagesHomePages template kit

Values and emptiness

What a binding's value is guaranteed to be by the time it reaches your markup, how a blank one is detected, and why formatting and the empty state are your code rather than the framework's.

Two guarantees about every value your renderer sees, and one division of labour that follows from them.

A binding’s value is exactly the shape its slot declared. bindSlots and bindItems normalize it once, on the way in, so there is no gap between what the schema promises and what arrives. A text slot is a string — never null, never a stray number that a template literal would print as itself.

Every binding carries present — whether that value is anything other than its kind’s blank. It is derived from the value, never declared beside it, so there is no second opinion about whether a slot is filled.

Everything a reader actually sees is yours. How a number reads on the page, and what a blank slot looks like, are a pure function and an ordinary conditional in your own section — not levers on a slot declaration.

One boundary is cheaper than universal defence. A content document outlives the schema that produced it, and a slot’s value arrives from the platform as opaque stored data. Somebody has to reconcile the two. Doing it once, where the binding is built, means the guarantee holds for every consumer at once — the primitives, your own JSX, and an island receiving a binding as a prop. Doing it at each read site is the alternative that gets built by accident: one section grew a hand-written string coercion and called it at ten separate places, because a single unguarded read anywhere in the file was enough to print [object Object] on a published page. That the binding is the only value path is a lint rule, not a convention — no-raw-slot-values.

Emptiness is computable, so it is not declarable. Whether a value is blank follows from the value itself, so an author lever for it could only ever agree with what is already knowable — or disagree with it, which is a bug that typechecks. No primitive takes an emptiness mode and no slot declares one. There is present, derived in one place, and plain React around it.

Presentation is template-owned. The kit ships no formatter library and no catalog of format names, because a currency, a unit or a phone number is a brand and market decision your template makes and a framework would have to guess at. A shipped vocabulary of format names is a vocabulary every template then bends itself to fit. A callback at the render site is not.

The editor invents nothing your markup did not render. An empty element that your own CSS gives no size collapses — identically on the published page and on the editor’s canvas. The framework has no basis for choosing what size an empty slot should occupy, and a click target that feels wrong (too large, oddly shaped for its context) is worse for the person editing than an honest collapse. So the canvas offers no invented box, and the editor’s sidebar stays the always-available path to every slot.

Normalization resolves anything unrecognizable to the slot kind’s blank rather than letting it reach your markup:

Slot kind Value Blank
text, select, url string ""
number number | null null
image the image value, or null null
video the video record the blank record — a poster with no rendition yet is a pending transcode, and counts as filled
tour the tour record the blank record — an embed with no poster is the ordinary state for a provider that publishes no thumbnail, and counts as filled
poi, document the row / the file, or null null
list an array []

Two consequences worth internalizing. A string-valued slot is a string even when nobody filled it, so .trim() on one cannot throw and ?? "" buys nothing. And a list is always an array, so .map cannot throw and ?? [] buys nothing either. Normalization reaches all the way down: a list’s members and a record row’s fields are each normalized by their own declaration.

There is deliberately no distinction between never filled and cleared. Both are the blank, because nothing downstream behaves differently for one than the other, and present is the single signal that replaces branching on null.

present is the structural non-blank test of the normalized value: a non-empty string, a non-null number, image, place or document, a non-empty list, a video or tour with a URL or a poster, or — for a record row — any field present. It is plain data computed when the binding is built, so a binding stays serializable and an island reads present exactly as the server-rendered markup does.

{slot.units.present ? <UnitTable slot={slot.units} /> : null}

Every value primitive takes a format callback — a pure function from the value to what should be displayed:

<Slot.Number slot={slot.list_price} as="span" format={(price) => `$${price.toLocaleString("en-US")}`} />

Three things are true of it. It runs only on a non-blank value — its parameter is non-nullable — so nothing inside a format callback is ever handed a blank to interpolate. It works the same on every value primitive and at every granularity, a whole slot or one field of one row. And it withholds the text leaf: the page shows $1,500,000 while the stored value is 1500000, so the editor must not treat that element’s text as the value itself.

That last point is a real trade, and it is inherent to formatting rather than to this design: an edit to a formatted slot round-trips through a re-render instead of updating as the user types. A raw, unformatted Slot.Text types live. The same trade already applies to Slot.Select, whose displayed label is never its stored key. Format where the formatting matters more than the typing latency, and leave the primary editing target raw where it does not.

Formatting shared across sections is an ordinary module beside them. Several templates keep a small local one; that is the sanctioned pattern, and the duplication between templates is deliberate isolation rather than a missing kit export.

Nothing needs doing for a slot to survive being empty: a primitive rendering a blank value still emits its marker, so the slot stays selectable and fillable. What you choose is how much of the rendering changes when it is:

  1. Do nothing. The primitive renders its marked empty element — an empty inline element, or an image slot’s placeholder box. Correct wherever a blank leaves nothing awkward behind.
  2. Change what fills it. One element, different children, branching on present — a fallback line where the real value is missing.
  3. Change the element itself. Fork on present with a primitive in each arm, when a blank calls for different markup entirely rather than different text inside the same markup.
{/* tier 3 — an embed when it is filled, a placeholder card when it is not */}
{slot.brochure_link.present
? <Slot.Url slot={slot.brochure_link} as="a">Download the brochure</Slot.Url>
: <Slot.Url slot={slot.brochure_link} as="div">Brochure coming soon</Slot.Url>}

Rendering nothing at all is the same branch with one empty arm — {slot.x.present && <Slot.Text …/>}. It is the right call only where the unfilled state genuinely has nothing to show, such as a second, optional copy of a value elsewhere on the page.

The collapse trade, stated plainly. Tier 1 with no size is the honest default, and it has a consequence: an empty element your CSS gives no height collapses to nothing, and something with no box on the page receives no click and shows no selection outline. That slot is then reachable only from the editor’s sidebar. Canvas selectability for an empty slot is earned exactly one way — by rendering an empty state that has real size. Declaring an image slot’s frame, giving a list a minimum height, or forking to a placeholder at tier 3 each do that. Sidebar reachability never depends on any of it.

The one thing you cannot do is drop the marker in the blank branch, and that is checked rather than trusted: each slot is rendered against a fixture with that slot emptied, and its marker must still be there — missing-slot-marker. A section that renders a slot only when it is filled fails that check, which is the mechanical form of “empty, but still selectable”.

There is no format name to put on a slot and no registry behind one. Formatting is the format callback at the render site, and a shared helper module is where a repeated one lives.

?? "", ?? [], ?? 0 and value ?? null chains have nothing to do against a binding — the value is already the schema’s shape. They reappear only where a section reads around the binding, which is what no-raw-slot-values refuses. What still needs your judgement is domain emptiness, which is a different thing: a neighborhood row with no name is a valid string value the schema is perfectly happy with, and whether it is worth rendering is your call, written as a filter on field-level present.

No primitive takes an emptiness prop, and no slot declares one. The branch that renders nothing is written in your own markup, where it is visible as a branch instead of hidden in a declaration.

The editor adds nothing to an empty element — no injected height, no placeholder chrome, no extra attribute. What an unfilled slot looks like is markup you control end to end, and the only thing the platform asks of it is that the marker survives.

  • Slot primitives — what each primitive does with a blank value, and the format and present mechanics per kind.
  • The slot kinds — the closed set each canonical blank is defined against.
  • no-raw-slot-values — the rule that keeps the binding the only value path.
  • render-invariant — the check that renders your section against emptied fixtures and reports what leaks.