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.
What it is
Section titled “What it is”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.
Why it works this way
Section titled “Why it works this way”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.
How it works
Section titled “How it works”One canonical blank per kind
Section titled “One canonical blank per kind”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 — the one absence signal
Section titled “present — the one absence signal”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}Formatting is a function you write
Section titled “Formatting is a function you write”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.
The empty state — three tiers
Section titled “The empty state — three tiers”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:
- 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.
- Change what fills it. One element, different children, branching on
present— a fallback line where the real value is missing. - Change the element itself. Fork on
presentwith 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”.
What it is not
Section titled “What it is not”Not a declared format
Section titled “Not a declared format”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.
Not nullability you defend against
Section titled “Not nullability you defend against”?? "", ?? [], ?? 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.
Not an emptiness lever
Section titled “Not an emptiness lever”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.
Not the editor’s job to size
Section titled “Not the editor’s job to size”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.
Where next
Section titled “Where next”- Slot primitives — what each
primitive does with a blank value, and the
formatandpresentmechanics 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.