All articles

Vue.js - Evolution From Renderless Component to Composable Function

This article explains the concept of renderless components and how to evolve them into composable functions using the Vue.js Composition API.

Published 9 min read

Vue.js - Evolution From Renderless Component to Composable Function

Introduction

An autocomplete can use the same search logic whether it displays results as a list or a grid of cards. We'll build that logic in a renderless component, then move it into a composable to compare how each approach connects to the template.

What is a renderless component?

A renderless component manages logic and state while its parent supplies the markup through scoped slots. It accepts props, exposes data and functions as slot props, and can emit events. In the autocomplete below, the component handles searching and selection; the parent renders the input and results.

Why use a renderless component?

Use this pattern when several interfaces need the same behavior but different markup. Here, one AutoComplete supplies search results to both a list and a card grid. A validation component can work the same way: it handles validation and exposes the field state so the parent can decide how to show errors. For an example, see Renderless components pattern in Vue 3.

How to build a renderless component in Vue.js

To illustrate the process of building a renderless component in Vue.js, we will create an AutoComplete component that can be used to search for a specific item in a list of items. The component accepts a list of items and, optionally, a search function as props. It keeps the search query itself, filters the items as the query changes, and passes the results to a slot, where the parent decides how to render them. It also emits a select event, so the parent knows which item was picked.

The slot exposes the query, results, and two functions: one to update the query and one to select an item. The parent uses these to connect its input and result controls to the search logic.

Basic example

AutoComplete.tsts
import { defineComponent, ref } from 'vue'

const AutoComplete = defineComponent({
  props: {
    items: {
      type: Array,
      required: true,
    },
    searchFn: {
      type: Function,
      required: false,
    },
  },
  emits: ['select'],
  setup(props, { emit, slots }) {
    const searchQuery = ref('') // Represents the search query entered by the user
    const results = ref([]) // Stores the search results based on the search query
    const onSearch = () => {
      if (searchQuery.value.length === 0) {
        results.value = []
        return
      }

      results.value = props.searchFn
        ? props.searchFn(searchQuery.value)
        : props.items.filter((item: any) => {
            return item.toLowerCase().includes(searchQuery.value.toLowerCase())
          })
    }

    const onSearchQueryChange = (val: string) => {
      searchQuery.value = val
      onSearch()
    }

    const select = (result: any) => {
      emit('select', result)
      searchQuery.value = ''
      results.value = []
    }

    return () => slots.default?.({
      searchQuery: searchQuery.value,
      results: results.value,
      onSearchQueryChange,
      select,
    })
  },
})

export default AutoComplete

Here we're going to use the component to render an input that provides the search query, then render the filtered items as an unordered list.

vue
<script setup lang="ts">
import AutoComplete from './AutoComplete'

const items = ['Vue.js', 'React', 'Angular', 'Svelte', 'Ember', 'Meteor']
</script>

<template>
  <div class="p-2">
    <AutoComplete v-slot="{ searchQuery, results, onSearchQueryChange }" :items="items">
      <input
        class="bg-transparent p-2 border rounded"
        type="text"
        :value="searchQuery"
        @input="onSearchQueryChange(($event.target as HTMLInputElement).value)"
      />
      <ul>
        <li v-for="result in results" :key="result">
          {{ result }}
        </li>
      </ul>
    </AutoComplete>
  </div>
</template>

Output

Search in: Vue.js, React, Angular, Svelte, Ember, Meteor

Advanced example

Suppose we have a list of frameworks along with their corresponding logos, and our goal is to display them as a grid of cards. As the items in the list are objects, we'll need to provide a custom search function to the AutoComplete component that searches based on the framework's name.

vue
<script setup lang="ts">
import AutoComplete from './AutoComplete'

const items = [
  { name: 'Vue.js', logo: '/blog-img/logos/vue.svg' },
  { name: 'React', logo: '/blog-img/logos/react.svg' },
  { name: 'Angular', logo: '/blog-img/logos/angular.svg' },
  { name: 'Svelte', logo: '/blog-img/logos/svelte.svg' },
  { name: 'Ember', logo: '/blog-img/logos/ember.svg' },
  { name: 'Meteor', logo: '/blog-img/logos/meteor.svg' },
]

const filterLibs = (searchQuery: string) => {
  return items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()))
}
</script>

<template>
  <div class="p-2">
    <AutoComplete v-slot="{ searchQuery, results, onSearchQueryChange }" :items="items" :search-fn="filterLibs">
      <input
        class="bg-transparent p-2 border rounded"
        type="text"
        :value="searchQuery"
        @input="onSearchQueryChange(($event.target as HTMLInputElement).value)"
      />
      <div class="grid grid-cols-3 gap-4 p-4">
        <div v-for="result in results" :key="result.name" class="rounded shadow p-4">
          <img :src="result.logo" :alt="result.name" class="w-24 h-24 mx-auto" />
          <p class="text-center">{{ result.name }}</p>
        </div>
      </div>
    </AutoComplete>
  </div>
</template>

Output

Search in: Vue.js, React, Angular, Svelte, Ember, Meteor

Selecting an item

So far, both examples only search. The component can also tell its parent which item was picked. The slot receives a select function: calling it emits a select event with the chosen item, then clears the search query and resets the list of results.

Because the list is reset, the button that was clicked is removed from the page. The example therefore moves the focus back to the input, so that keyboard users keep their place.

vue
<script setup lang="ts">
import { ref } from 'vue'
import AutoComplete from './AutoComplete'

const items = ['Vue.js', 'React', 'Angular', 'Svelte', 'Ember', 'Meteor']
const selected = ref('')
const input = ref<HTMLInputElement>()

const onSelect = (item: string) => {
  selected.value = item
  // The clicked button is gone, so the input takes the focus back
  input.value?.focus()
}
</script>

<template>
  <div class="p-2">
    <AutoComplete
      v-slot="{ searchQuery, results, onSearchQueryChange, select }"
      :items="items"
      @select="onSelect"
    >
      <input
        ref="input"
        class="bg-transparent p-2 border rounded"
        type="text"
        :value="searchQuery"
        @input="onSearchQueryChange(($event.target as HTMLInputElement).value)"
      />
      <ul>
        <li v-for="result in results" :key="result">
          <button type="button" @click="select(result)">{{ result }}</button>
        </li>
      </ul>
    </AutoComplete>
    <p>Selected: {{ selected || 'nothing yet' }}</p>
  </div>
</template>

Output

Search in: Vue.js, React, Angular, Svelte, Ember, Meteor

Selected: nothing yet

The search state and filtering logic do not depend on the markup, so we can also put them in a composable. The component that calls it receives reactive values directly instead of through a slot.

What's a composable function?

A composable is a function that uses Vue's Composition API to encapsulate reusable stateful logic. It can use reactivity, lifecycle hooks, and dependency injection.

Basic example

Let's start by creating a composable function that handles the logic of searching for a specific item in a list of items. The composable function accepts a list of items and, optionally, a search function. It returns the search query and the list of results, which updates whenever the query changes.

useAutocomplete.tsts
import { computed, ref } from 'vue'

export default function useAutocomplete<T = string>(items: T[], searchFn?: (query: string) => T[]) {
  const searchQuery = ref('') // Represents the search query entered by the user
  const results = computed<T[]>(() => {
    // Stores the search results based on the search query
    if (searchQuery.value.length === 0) {
      return []
    }

    return searchFn
      ? searchFn(searchQuery.value)
      : items.filter((item) => {
          return String(item).toLowerCase().includes(searchQuery.value.toLowerCase())
        })
  })

  return {
    searchQuery,
    results,
  }
}

Now, we can use the composable function to render an input that provides the search query, then render the filtered items as an unordered list.

vue
<script setup lang="ts">
import useAutocomplete from './useAutocomplete'

const items = ['Vue.js', 'React', 'Angular', 'Svelte', 'Ember', 'Meteor']

const { searchQuery, results } = useAutocomplete(items)
</script>

<template>
  <div class="p-2">
    <input v-model="searchQuery" class="bg-transparent p-2 border rounded" type="text" />
    <ul>
      <li v-for="result in results" :key="result">
        {{ result }}
      </li>
    </ul>
  </div>
</template>

Output

Search in: Vue.js, React, Angular, Svelte, Ember, Meteor

For this example, the composable removes the component wrapper and slot bindings. The caller binds searchQuery with v-model and renders results directly. Selection also belongs to the caller: its click handler can store the chosen item and clear searchQuery.

Advanced example

Let's now pass a search function to the composable and render the results as a grid of cards. The search function is used to filter the list of items based on the search query.

vue
<script setup lang="ts">
import useAutocomplete from './useAutocomplete'

const items = [
  { name: 'Vue.js', logo: '/blog-img/logos/vue.svg' },
  { name: 'React', logo: '/blog-img/logos/react.svg' },
  { name: 'Angular', logo: '/blog-img/logos/angular.svg' },
  { name: 'Svelte', logo: '/blog-img/logos/svelte.svg' },
  { name: 'Ember', logo: '/blog-img/logos/ember.svg' },
  { name: 'Meteor', logo: '/blog-img/logos/meteor.svg' },
]

const filterLibs = (searchQuery: string) => {
  return items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()))
}

const { searchQuery, results } = useAutocomplete(items, filterLibs)
</script>

<template>
  <div class="p-2">
    <input v-model="searchQuery" class="bg-transparent p-2 border rounded" type="text" />
    <div class="grid grid-cols-3 gap-4 p-4">
      <div v-for="result in results" :key="result.name" class="rounded shadow p-4">
        <img :src="result.logo" :alt="result.name" class="w-24 h-24 mx-auto" />
        <p class="text-center">{{ result.name }}</p>
      </div>
    </div>
  </div>
</template>

Output

Search in: Vue.js, React, Angular, Svelte, Ember, Meteor

Conclusion

Both versions filter the same items and leave the markup to the component that uses them. The renderless version provides state and actions through a slot and reports selection with an event. The composable returns the search state directly; the caller handles selection and clearing the query.