How to Add Fallback Slot Content for Qwik Applications

In this post, you'll learn how to create a custom slot component for Qwik that supports fallback content, as you find with Vue.js or Svelte.

When building components in any JavaScript framework, I think the best place to start is with the component’s API design. How should it work when being consumed?

Say, for example, we wanted a very simple component (simple for the sake of convenient blogging) that provides a greeting, but whose content can also be customized. We’ll use the canonical example for “Hello World!”

We want the API for this component to work like this:

  • By default, the component will print “Hello World!”
  • The target of the greeting (‘World’) can be customized by providing content inside the component slot.
  • The greeting (‘Hello’) can be customized by providing content inside the component tags, and marking it as a named slot.

The experience might look like this:

<Greet />
<!-- Hello World! -->

<Greet>Austin</Greet>
<!-- Hello Austin! -->

<Greet>
  <q:template slot="greeting">Que onda</q:template>
  Miguel
</Greet>
<!-- Que onda Miguel! -->

Admittedly, that last one is less than ideal, but hang with me. First off, Qwik uses the <q:template /> tag as placeholder. It gets removed during transpilation which is how we get the resulting plain text, Que onda Miguel! We could use a span instead of q:template, which would look nicer, but would result in <span>Que onda</span> Miguel! Sometimes, you don’t want that. Also, for this contrived example, it would make more sense to use a prop, but this post is about slots. It’ll make sense in the end :)

Qwik Component Background

Like many JavaScript frameworks, Qwik uses JSX for templating. A basic JSX component can look like this:

export default (props) => {
  return <div>Hello World!</div>;
};

Technically, this works in Qwik. However, one of the major things that sets Qwik apart is the way it “automagically” handles performance improvements (such as lazy-loading components). It does this via the Qwik Optimizer which relies on wrapping a traditional JSX component (above) inside a function called component$.

import { component$ } from '@builder.io/qwik';
 
export default component$(() => {
  return <div>Hello World!</div>;
});

This is what allows the necessary code transformations at build time to allow for the smart lazy loading that Qwik offers. It also plays into their concept of Resumability.

More from the docs:

The component$ function, marked by the trailing $, enables the Optimizer to split components into separate chunks. This allows each chunk to be loaded independently as needed, rather than loading all components whenever the parent component is loaded. Note: index.tsx, layout.tsx in routes folder, root.tsx and all entry files need export default. For other components, you can use export const and export function.

Building Our Component

With the component background out of the way, we can start to build our Qwik component. We can use the component above, but in order to support the API design for customization, we’ll want to use slots.

Conveniently, Qwik provides a <Slot /> component that makes it easy to insert the slotted content exactly where we need it to go.

We can use a default slot for the target, and a named slot for the greeting (we also need a non-breaking space character &nbsp; to separate the two):

import { component$, Slot } from '@builder.io/qwik';
 
export default component$(() => {
  return <div>
    <Slot name="greeting" />
    &nbsp;
    <Slot />
    !
  </div>;
});

The Challenge: Default Content

The component above will work fine as long as we explicitly provide content for the ‘greeting’ slot and default slot, but our API design falls back to “Hello” for the greeting if none is provided, and “World” for the target (default slot) if none is provided.

If you’ve worked with frameworks like Vue.js or Svelte, you may be familiar with the concept of fallback content when using slots. It allows you to provide fallback or default content for a slot if none is provided when the component is in use:

<slot>Show this if the slot is not used</slot>

Unfortunately, Qwik does not support this :(

The Problem: No Children

Qwik’s <Slot/> component sadly does not support fallback content, but the problem could still be solved with conditional rendering.

If our component is provided content between its tags (<Greet>like this</Greet>), we could examine the properties of that content by checking props.children.

Or at least, we could do that with JSX. However, another limitation here is that when we use Qwik’s component$ function, we no longer have access to props.children.

It has something to do with the concept of Projection which I’ll skip, but you can read about here.

I guess we could solve the issue by using a regular JSX component, but then we’d lose all the benefits of the Qwik Optimizer.

The Fix: Inline Components

Fortunately, a solution was presented to me by Jack Shelton, one of the core contributors to Qwik, and a moderator of the Qwik Discord server.

We could use a JSX function component to access the props (and children), then wrap that parent component around a standard Qwik component using the component$ function. In this way, the child could have access to the parent’s props, while still having the benefits of the Qwik optimizer.

A simplified version might look like this:

export default (parentProps) => {
  console.log(parentProps.children) // Slot content :)

  const ChildComponent = component$((childProps) => {
    return <div>
      content
    </div>
  })
  return <ChildComponent {...parentProps} />
}

Notice that this is a functional component that creates within it, its own child component, then returns that child component with the parent’s props applied to it. This is effectively the same as just creating a single component. Both child and parent component receive the same props and output the same results, but there’s one important distinction.

Because the parent component is an inline component, it has access to the children property of props, which allows us to add some conditional logic to the child based on the state of the children.

(I hope that made sense)

Conditionally Rendering Slots

Now that we have access to the component’s slot content, we need to sort out the logic of what to show when. Let’s look at some options. Keep in mind that our <Greet> component has one named slot (“greeting”) and the default slot:

No Children Defined

If the component is used without any slot content (or children) it should render the fallback content for the named slot and the default slot:

<Greet />
<!-- Hello World! -->

Default Child/Children Defined

If anything is placed inside the tags that is not marked as a named slot, it should replace the greeting target. Note that I explicitly mention child vs children because a default slot can contain zero, one, or many child nodes. This will be relevant later.

<Greet>Austin</Greet>
<!-- Hello Austin! -->

<Greet>
  <b>Austin</b>
  &nbsp;&amp;&nbsp;
  <i>nugget</i>
</Greet>
<!-- Hello Austin & Nugget! -->

Named Slot Child Defined

If the component has a child intended for the named slot (there can be only one), it should render that child instead of the fallback.

<Greet>
  <q:template slot="greeting">What a wonderful</q:template>
</Greet>
<!-- What a wonderful World! -->

Quick Note on Children

From the inline component, if you were to console.log(props.children), you will get varying results:

  • If the component has no slot content, you will get undefined.
  • If there is a single element or text in the component, you will get that child, but:
    • If it’s just text without wrapping tags, you will see just a string.
    • If it’s wrapped in a tag, you will see a JSXNode object.
  • If there are more than one child, you will see an array of JSXNode objects and strings for any unwrapped text.

Loggign the children in this example:

<Greet>
  <q:template slot="greeting">Que onda</q:template>
  Miguel
</Greet>

Would produce this result:

[
  JSXNodeImpl {
    type: 'q:template',
    props: {},
    immutableProps: { slot: 'greeting' },
    children: 'Que onda',
    flags: 3,
    key: null,
    dev: {
      stack: 'STACK_TRACE_STRING',
      lineNumber: 204,
      columnNumber: 11
    }
  },
  'Miguel'
]

The key thing to note here is that sometimes we’re dealing with no children, sometimes it’s a single child, sometimes a list of children. Sometimes the children will be strings, and sometimes they may be objects.

In the case of objects, we will have access to a list of objects with the following information.

immutableProps: { slot: 'greeting' }

The Solution: <SlotWithFallback/>

Now, we could write all of that logic into our component everywhere we would otherwise use a <Slot>, but that would be tedious and makes for a good opportunity for abstraction. In other words, ANOTHER COMPONENT!!!

/**
 * @typedef {import("@builder.io/qwik").JSXNode & {
 * immutableProps: { slot: string }
 * }} Child
 * @type {import("@builder.io/qwik").FunctionComponent<{
 * parentProps: { children?: Child | Child[] },
 * name?: string,
 * children: import("@builder.io/qwik").JSXOutput
 * }>}
 */
const SlotWithFallback = ({ name, parentProps, ...props }) => {
  const fallback = props.children
  let children = parentProps.children

  if (!children) {
    return fallback
  }
  // If there is only one child, children will be an object, not an array
  if (!Array.isArray(children)) {
    children = [children]
  }

  if (name) {
    // For named slots, return only named slot content or fallback
    const namedSlotContent = children.find(child => {
      if (typeof child === 'string') {
        return false
      }
      return child.immutableProps.slot === name
    })
    return namedSlotContent ?? fallback
  }
  // For unnamed slots, return only unnamed slot content or fallback
  const defaultSlotContent = children.filter(child => {
    if (typeof child === 'string') {
      return true
    }
    return !child.immutableProps.slot
  })

  return defaultSlotContent.length ? defaultSlotContent : fallback
}

This function expects to receive a parentProps prop which may contain a children property. This is where the default slot and named slot content may come from. It may also have its own props.children, which serves as the fallback content.

If a name prop is defined, we know this is the named slot, and therefore should show ONLY the child content intended for the named slot, OR the fallback content for the named slot if no matching child is found.

If the name prop is not defined, we should try to chow all children that are not tagged for a named slot, or show the fallback.

After all of this, we can use this new slot component inside our parent/child mutant hybrid to simplify the logic. We’ll need to and provide the JSX props from the inline parent to this new slot component’s parentProps prop. We can also provide some fallback content inside the component tags in case custom content is not provided when consumed.

In the end, our <Greet/> component could look something like this:

export default (parentProps) => {
  const ChildComponent = component$((childProps) => {
    return <div>
      <SlotWithFallback name="greeting" parentProps={parentProps}>
        Hello
      </SlotWithFallback>
      &nbsp;
      <SlotWithFallback parentProps={parentProps}>
        World
      </SlotWithFallback>
      !
    </div>
  })
  return <ChildComponent {...parentProps} />
}

Limitations

Inline component provide a handy solution for today’s issue, but they are not without their trade-offs. As per the docs:

Inline components come with some limitations that the standard component$() does not have. Inline components:

  • Cannot use use* methods such as useSignal or useStore.
  • Cannot project content with a <Slot>.

As the name implies, inline components are best used sparingly for lightweight pieces of markup since they offer the convenience of being bundled with the parent component.

Real World Example: Dynamic Slot Names

This was a lot of work for a relatively contrived example. In a real world app, you’d probably solve this issue with a more simple and straightforward solution. But there is a reason I came down this rabbit hole in the first place.

I was building a table component to display rows and columns of data. I wanted to be able to loop over the data, but I also wanted each cell (or column in my case) to support custom templates defined in the parent.

This was a perfect use-case for named slots.

The issue was that I wouldn’t actually know what they slot names would be. They would be dynamically generated based on the data in the table.

Here’s the part of my code that does that. The key line being name={column.${column.key}}

<tbody>
  {items.map((item, index) => (
    <tr key={index}>
      {headers.map((column) => (
        <td key={column.key} class={cellStyles}>
          <SlotWithFallback parentProps={props} name={`column.${column.key}`} >
            {item[column.key]}
          </SlotWithFallback>
        </td>
      ))}
    </tr>
  ))}
</tbody>

In this way, I can use my custom <Table> component and customize the table columns by targeting the specific column via slot.

<Table
  headers={[
    { key: 'email', content: 'Email' },
  ]}
  items={[userRef.value]}
>
  <q:template slot="column.email">Custom Content!</q:template>
</Table>

Closing

This SlotWithFallback component is pretty cool, and it could easily be adapted to work with other JSX-based frameworks like React. I haven’t tested, but in theory, they should be close.

Sadly, this component is not 100% ideal. Yes, we can overwrite the slot content with some fallback content, but in my example above with dynamic slots from a list of data, I would want to customize the slot content based on the current item in the iteration. In JSX, you might solve this with a render prop. Qwik doesn’t support this, due to its server-first architecture. It’s also annoying that you still have to jump through some hoops to get things working with that inline component and the parentProps prop, but there’s good news.

My discussion on the Discord server also caught the attention of Wout Mertens that chimed in to say that the default <Slot> component from Qwik will support fallback content in the next major version.

So, I guess I hope you found this helpful, interesting, and useful for the time being :)




Thank you so much for reading. If you liked my work, and want to support it, the best ways are donating, sharing, or following along on email or Bluesky.


Originally published on austingil.com.

Leave a Reply

Your email address will not be published. Required fields are marked *