Skip to content

Instantly share code, notes, and snippets.

@Justineo
Last active April 3, 2022 05:36
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Justineo/3aeb40baff4341b218980dc6318bae17 to your computer and use it in GitHub Desktop.
Save Justineo/3aeb40baff4341b218980dc6318bae17 to your computer and use it in GitHub Desktop.
Coupled parent-child component pair

Patterns for coupled parent-child component pairs

Let's consider a <Select> component. We can provide two kind of APIs to define its options:

  1. Provide a datasource prop options

    <Select v-model="selectValue" :options="projects"/>

    This is useful when we are rendering options directly returned by something like a remote datasource. In this case we do not care much about the semantics of the option data.

  2. Provide inline child components

    <Select v-model="selectValue">
      <Option value="vue">Vue</Option>
      <Option value="vite">Vite</Option>
    </Select>

    This is helpful when we are rendering some known options, especially for those that we want different rendering logic for each one (will discuss later).

    And we can also use this pattern to render options from remote datasource:

    <Select v-model="selectValue">
      <Option v-for="{ label, value } in projects" :key="value" :value="value">{{ label }}</Option>
    </Select>

Customizing renderers

Now if we want to enable our users to customize the rendering of each option, we can provide a (scoped) slot for that purpose:

<Select
  v-model="selectValue"
  :options="projects"
>
  <template #option="{ label, value }">
    <Icon :type="value"/> {{ label }}
  </template>
</Select>

If all option are using the same customization logic, this will be very neat.

But when we have different render pattern for each option, this may start to become less straightforward:

<Select
  v-model="selectValue"
  :options="projects"
>
  <template #option="{ label, value }">
    <template v-if="value === 'vue'">
      <!-- render vue -->
    </template>
    <template v-else-if="value === 'vite'">
      <!-- render vite -->
    </template>
    <template v-else-if="value === 'react'">
      <!-- render react -->
    </template>
  </template>
</Select>

This is where the inline pattern becomes handy:

<Select v-model="selectValue">
  <Option value="vue">
    <!-- render vue -->
  </Option>
  <Option value="vite">
    <!-- render vite -->
  </Option>
  <Option value="react">
    <!-- render react -->
  </Option>
</Select>

When we have more complex structures with combination of components like <OptGroup>, the inline pattern provide better DX (in my opinion) which helps us writing simpler custom render logic in templates and saves us from jumping back and forth from <template> and <script>.

Higher order encapsulation

Another example would be a <Table> component. We need data/config from both rows and columns for <Table>s. Row data usually are lists like projects in the previous example. Column data are more like the known configs and are more likely to be defined as literals. We may want to define props like field, width, sortable, etc. for each column, at the same time, each column are likely to be rendered differently in complex scenarios. So the major concern here is how we provide both data and custom renderer for each column.

Let's take a look at the API provided by Element UI:

<el-table
  :data="tableData"
>
  <el-table-column prop="date" label="Date" sortable>
    <template #default="{ date }">
      {{ formatDate(date) }}
    </template>
    <template #header="{ label }">
      {{ label }} <Icon type="calendar"/>
    </template>
  </el-table-column>
  <el-table-column prop="name" label="Name"/>
  <el-table-column prop="address" label="Address"/>
</el-table>

Which I believe is quite intuitive for end users. If we translate this into Vuetify's <DataTable> it will be like:

<v-data-table
  :headers="[
    { text: 'Date', value: 'date' },
    { text: 'Name', value: 'name' },
    { text: 'Address', value: 'address' },
  ]"
  sort-by="date"
  :items="tableData"
>
  <template v-slot:item.date="{ item }">
    {{ formatDate(item.date) }}
  </template>
  <template v-slot:header.date="{ header }">
    {{ header.text }} <Icon type="calendar"/>
  </template>
</v-data-table>

Vuetify is providing a dynamic slot which includes a micro syntax in its name (item.<field>). It uses a modifier syntax but actually it's not. The problem here is that we need to add all column-wise features to <DataTable> and we'll have to spread data/custom renderer for a column across different props/slots on table. This also make it harder to encapsulate reusable biz logic, eg. provide higher order components for a column, or a reusable column group. With the inline children approach we can have:

<Table :data="tableData">
  <Column field="id"/>
  <LinkColumn field="url">
  <MetricsColumns/>
  <Column field="actions">
    <template #default="{ id }">
      <Link :to="`/page/detail/${id}`"
    </template>
  </Column>
</Table>

Where <LinkColumn> being:

<Column :field="field">
  <template #default="item"><Link :to="item[field]">{{ item[field] }}</Link></template>
</Column>

...and <MetricsColumns> being:

<Column field="ttfb"/>
<Column field="fp"/>
<Column field="fcp"/>
<Column field="tti"/>

This is not possible with Vuetify's current API.

Implementation on component library side

To implement the inline children pattern, currently there can be two possible approaches:

  1. Traverse and transform children vnodes ($slots.default in Vue 2, $slots.default() in Vue 3) in the parent component's render function.
  2. Child components “register” themselves to their parents' local data in created/mounted hook and then the parent renders according to the registered children data.

Traversing in render

This approach have a limitation that we do not have the rendered result of the children. i.e. We can only handle trivial cases like:

<Select v-model="selectValue">
  <Option value="vue">Vue</Option>
  <Option value="vite">Vite</Option>
</Select>

If we wrap the <Option> component with our custom <MyOption> component, the <Select> component won't be able to recognize it even <MyOption> will finally produce an <Option> component.

Register upwards from children

This approach would make the API more flexible which enables the higher order encapsulation.

But as we are relying on when each child's created/mounted being called, the order may become unexpected if we have some dynamically rendered children:

<Select v-model="selectValue">
  <Option v-if="showVue" value="vue">Vue</Option>
  <Option value="vite">Vite</Option>
</Select>

If showVue is false at first and then set to true, the children data the parent received will be:

[
  { value: 'vite', ... },
  { value: 'vue', ... }
]

To prevent this, usually component library authors would grab parent components' $slot.default during children registration and find the correct index before inserting itself into its parent's children array.

The upcoming concern about this pattern is that in Vue 3 we 1. no longer stores component instances on vnodes 2. returns fresh vnodes on each $slots.default() call, so that there's no reliable way to help child components finding their correct insertion index.

@nekosaur
Copy link

nekosaur commented May 10, 2021

In Vuetify v3 we are currently 'solving' the issue of provide/inject by adding an index prop to the child component that will have to be supplied whenever you are dynamically rendering children. That way we can insert the item in the correct position.

It is not very good DX and far from perfect, so we would definitely welcome a built-in solution.

@Justineo
Copy link
Author

In Vuetify v3 we are currently 'solving' the issue of provide/inject by adding an index prop to the child component that will have to be supplied whenever you are dynamically rendering children. That way we can insert the item in the correct position.

Thank you. Can you point us to Vuetify's current implementation about this?

@nekosaur
Copy link

Here is one of our composables that handles selecting item(s) in a group https://github.com/vuetifyjs/vuetify/blob/next/packages/vuetify/src/composables/group.ts.

useGroup is used in Parent component, useGroupItem in Child.

Here https://github.com/vuetifyjs/vuetify/blob/next/packages/vuetify/src/composables/group.ts#L87-L92 we register children and put them in correct order if index is supplied.

@Justineo
Copy link
Author

Justineo commented May 11, 2021

@nekosaur Thanks! I didn't find any reference to useGroupItem. How are child components suppose to know the indices of themselves?

@nekosaur
Copy link

nekosaur commented May 11, 2021

Usage is something like

// Parent
defineComponent({
  props: { ... },
  setup (props, ctx) {
    const stuff = useGroup(props)
    
    return () => h('div', ctx.slots.default?.()
  }
})
// Child
defineComponent({
  props: {
    index: Number,
    ...
  },
  setup (props, ctx) {
    const { toggle } = useGroupItem(props)

    return () => h('div', { onClick: toggle }, ctx.slots.default?.())
  }
})
<template>
  <parent v-model="selected">
    <child>foo</child>
    <child v-if="show" :index="1">bar</child>
    <child>baz</child>
  </parent>
</template>

We don't actually have any components using this composable yet. This PR is on its way in vuetifyjs/vuetify#13319

@Justineo
Copy link
Author

I see. This requires manually providing index whenever needed, which leaks the workaround into userland...I'd avoid this as much as possible. We don't need this in Vue 2 so ideally we should try to keep this pattern working in Vue 3.

@nekosaur
Copy link

I see. This requires manually providing index whenever needed, which leaks the workaround into userland...I'd avoid this as much as possible. We don't need this in Vue 2 so ideally we should try to keep this pattern working in Vue 3.

It is not very good DX and far from perfect, so we would definitely welcome a built-in solution.

I very much agree :)

@Justineo
Copy link
Author

@nekosaur After discussing this with Evan, he suggested that we can set a key for the default slot and look for the corresponding fragment vnode within the parent component's subTree. This seems to work for us for now.

@nekosaur
Copy link

Hmm ok. Do you have some sample code for how this would be done?

@Justineo
Copy link
Author

Justineo commented Feb 7, 2022

@nekosaur I've extracted this pattern into a separate package (as a PoC now): https://github.com/Justineo/vue-coupled

I've also checked Vuetify's current useGroup / useGroupItem pair and it seems to be more than a coupled parent-child pattern so this may be a bit different. And after the last time we discussed we found out that only trying to figuring out the correct index when children are created is not enough. If children aren't destroyed/created but are only re-ordered, we don't have a chance to update the parent's children array so we adopted a different strategy. If you are interested in discussing this a bit more, feel free to share your thoughts 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment