Children
The content you put inside a component's tags is handed to it as a prop called
children. That lets you write a component that wraps whatever it is given.
export class Panel extends Component<{ title: string; children?: RamondaNode }> {
render() {
return (
<section className="panel">
<h2>{this.props.title}</h2>
{this.props.children}
</section>
);
}
}
<Panel title="Settings">
<p>Anything at all.</p>
</Panel>
Panel draws its own frame and drops whatever it was given where
{this.props.children} sits.
More than one slot
There is no special "slot" syntax — if you need to place content in more than one spot, take more than one prop. Markup is just a value, so you can pass it like any other:
export class Dialog extends Component<{
header: RamondaNode;
children?: RamondaNode;
footer?: RamondaNode;
}> {
render() {
return (
<div className="dialog">
<header>{this.props.header}</header>
<div className="body">{this.props.children}</div>
{this.props.footer ? <footer>{this.props.footer}</footer> : null}
</div>
);
}
}
<Dialog header={<h2>Confirm</h2>} footer={<button>OK</button>}>
<p>Are you sure?</p>
</Dialog>
A slot belongs where it lands
Markup you pass through a prop is placed by the component that receives it, and that is the
component it belongs to: its @created runs with that one's subtree, its @destroyed with it, and a
context it reads is the one above the place it renders, not the one above
the file it was written in.
That has a consequence worth knowing before you put state in a component you intend to move. A slot is found again by its position, so two slots that trade content do not trade instances:
export class Toolbar extends Component {
@state swapped = false;
render() {
return (
<Dialog
header={this.swapped ? <Badge label="b" /> : <Badge label="a" />}
footer={this.swapped ? <Badge label="a" /> : <Badge label="b" />}
/>
);
}
}
Flipping swapped looks like the two badges changed places. They did not: the Badge in the header
stays where it is and is handed the other one's props, and the same for the footer. Nothing is
created and nothing is destroyed — so whatever state each Badge held stays in the header and the
footer respectively, under new content.
Give them a key when you mean the content to carry its state with it:
export class KeyedToolbar extends Component {
@state swapped = false;
render() {
return (
<Dialog
header={this.swapped ? <Badge key="b" label="b" /> : <Badge key="a" label="a" />}
footer={this.swapped ? <Badge key="a" label="a" /> : <Badge key="b" label="b" />}
/>
);
}
}
Then the two are torn down and rebuilt in their new places, which is what moving actually costs.
Another trap: changing the shape around a slot
A component may return several siblings, so it is natural to wrap a slot in an array. Write the condition inside the array rather than around it:
interface BusyProps {
busy: boolean;
children?: RamondaNode;
}
// keeps the slot
export class Keeps extends Component<BusyProps> {
render() {
return [this.props.busy ? <Badge label="loading" /> : null, this.props.children];
}
}
// rebuilds it
export class Rebuilds extends Component<BusyProps> {
render() {
return this.props.busy ? [<Badge label="loading" />, this.props.children] : [this.props.children];
}
}
Both render the same two shapes. The second writes them as two different arrays, so children sits
at position 1 in one and position 0 in the other — and position is how a child is found again. The
markup comes out right either way, which is what makes this easy to miss; what goes is the state
inside the slot, because it is torn down and built anew.
A null in the array holds the place. That is all the first spelling is doing.
One trap: passing an unkeyed list as children
If a component wraps its children in its own markup, it can't control how the caller
built those children. If the caller passes a bare .map(), items get matched by
position — and as the list grows, the wrapper's own markup can be mistaken for a list
item, so state lands on the wrong node. There is no visible error; development reports
it.
The fix is the caller's, and it is list() instead of .map():
// instead of {items.map((item) => <li>{item.name}</li>)}
<Panel>{list(items, (item) => <Item key={item.id} item={item} />)}</Panel>
list() keeps the rows as one child rather than letting them mix with the panel's own
markup, so nothing around them can be confused for part of the list.
It identifies a row by what sets that row apart from its siblings — which works while your
objects are the ones you built, and stops working the moment the data is replaced from
outside: a refetch or a JSON.parse hands over objects nothing has seen, so every row is
rebuilt and whatever it was holding goes with it. When that happens, say which field names a
row where the data arrives:
this.rows = merge(this.rows, await api.getRows(), (row) => row.id);
Said once, at the boundary, rather than on every list that renders those rows.
RMD051 reports a row that carries nothing to tell it apart, and
lists has the whole story.
Next
- Error boundaries — containing a failure.