Way4Tech

Part 2. Components · lesson 9 of 40

Slots: components that take content

After this lesson you can: Build a component whose content the parent supplies, including named slots and slots that pass data back.

Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.

Props pass data. Slots pass markup. When you want a card, a dialog or a panel whose contents the parent decides, you want a slot.

The default slot

<!-- The reusable shell -->
<t t-name="owl_course.Panel">
    <div class="border rounded p-3">
        <t t-slot="default"/>
    </div>
</t>
export class Panel extends Component {
    static template = "owl_course.Panel";
    static props = { slots: { type: Object, optional: true } };
}
<!-- Using it -->
<Panel>
    <h4>Outstanding invoices</h4>
    <p>Nothing is overdue this month.</p>
</Panel>

Everything between the tags lands where t-slot="default" sits.

Declare slots in static props. OWL passes slot content as a prop called slots, so a component with validation turned on and no slots declared will reject its own children.

Named slots

One slot is rarely enough. A panel usually wants a header, a body and a footer.

<t t-name="owl_course.Panel">
    <div class="border rounded">
        <div class="border-bottom p-2 fw-bold">
            <t t-slot="header"/>
        </div>
        <div class="p-3">
            <t t-slot="default"/>
        </div>
        <div t-if="props.slots.footer" class="border-top p-2 text-end">
            <t t-slot="footer"/>
        </div>
    </div>
</t>
<Panel>
    <t t-set-slot="header">Outstanding invoices</t>

    <p>Nothing is overdue this month.</p>

    <t t-set-slot="footer">
        <button class="btn btn-sm btn-primary" t-on-click="refresh">Refresh</button>
    </t>
</Panel>

Content not inside a t-set-slot goes to the default slot.

Checking whether a slot was filled

props.slots is an object keyed by slot name, so the test is simply whether the key exists:

<div t-if="props.slots.footer" class="border-top p-2">
    <t t-slot="footer"/>
</div>

Without that check you render an empty bordered strip whenever a parent omits the footer. This is the difference between a component that is pleasant to reuse and one everybody works around.

Slot props: passing data back out

The powerful version. A slot can hand values to the markup the parent wrote.

<!-- A list that owns the data and the loop, but not the row markup -->
<t t-name="owl_course.RecordList">
    <ul class="list-unstyled">
        <li t-foreach="state.records" t-as="record" t-key="record.id">
            <t t-slot="row" record="record" index="record_index"/>
        </li>
    </ul>
</t>
<RecordList model="'account.move'">
    <t t-set-slot="row" t-slot-scope="scope">
        <strong t-esc="scope.record.name"/>
        <span t-esc="scope.record.amount_total"/>
    </t>
</RecordList>

t-slot-scope names the object carrying everything the slot was given. Now RecordList owns fetching, loading state and pagination, while every caller decides what a row looks like. That is the shape of most genuinely reusable components.

Default content

A slot can have a fallback used when the parent supplies nothing:

<t t-slot="empty">
    <p class="text-muted">Nothing here yet.</p>
</t>

When not to use a slot

A slot is the right tool when the parent should control markup. It is the wrong tool when the parent should control data.

<!-- Wrong: that is a prop -->
<Panel>
    <t t-set-slot="title">Invoices</t>
</Panel>

<!-- Right -->
<Panel title="'Invoices'"/>

A title is a string. Passing it as a slot means it cannot be used in an attribute, cannot be validated, and cannot be read by the component for anything other than printing.

The test: if the component needs to know the value, it is a prop. If the component only needs to place it, it is a slot.

A real shell

Putting it together, this is close to how a reusable section is actually written in a module:

export class Section extends Component {
    static template = "owl_course.Section";
    static props = {
        title: { type: String },
        collapsible: { type: Boolean, optional: true },
        slots: { type: Object, optional: true },
    };
    static defaultProps = { collapsible: false };

    setup() {
        this.state = useState({ open: true });
    }
}
<t t-name="owl_course.Section">
    <section class="mb-4">
        <header class="d-flex justify-content-between align-items-center">
            <h5 class="mb-0" t-esc="props.title"/>
            <div t-if="props.slots.actions">
                <t t-slot="actions"/>
            </div>
            <button t-if="props.collapsible" class="btn btn-sm btn-link"
                    t-on-click="() => this.state.open = !this.state.open">
                <t t-esc="state.open ? 'Hide' : 'Show'"/>
            </button>
        </header>
        <div t-if="state.open" class="mt-2">
            <t t-slot="default"/>
        </div>
    </section>
</t>

The title is a prop because the component prints it and may need it. The actions are a slot because the component only places them.

What is next

The judgement call nobody teaches: when one component should become two.

Back to the full course