Using Scoped Slots in Vue.js to Abstract Functionality

Let’s start with a short introduction to Vue.js slots concept. Slots are useful when you want to inject content in a specific place of a component. Those specific places that you can define are called slots.

For example, you want to create a wrapper component that is styled in a specific way but you want to be able to pass any content to be rendered inside that wrapper (it might be a string, a computed value, or even another component).

There are three types of slots:

  • default / unnamed slots: used when you have a single slot in a component. We create them by adding <slot> in the template where we want to be able to inject our content. This <slot> tag will be replaced with any content passed to the component’s template.
  • named slots: used when you have multiple slots in a component and we want to inject different content in different places (slots). We create those by adding <slot> with a name attribute (e.g. <slot name="header"></slot>). Then when we render our component, we provide a slot content for each named slot by adding a slot attribute with the slot name.
<base-layout>
  <template slot="header">
    <h1>My awsome header</h1>
  </template>
  <template slot="footer">
    <p>My awsome footer</p>
  </template>
</base-layout>

By doing that, the <slot> tags in the component will be replaced by content passed to the component.

  • scoped slot: used when you want a template inside a slot to access data from the child component that renders the slot content. This is particularly useful when you need freedom in creating custom templates that use the child component’s data properties.
scoped slots diagram

Real-World Example: Creating a Google Map Loader component

Imagine a component that configures and prepares an external API to be used in another component, but is not tightly coupled with any specific template. Such a component could then be reused in multiple places rendering different templates but using the same base object with specific API.

I’ve created a component (GoogleMapLoader.vue) that:

  1. initializes the Google Maps API
  2. creates google and map objects
  3. exposes those objects to the parent component in which the GoogleMapLoader is used

Below is an example of how this can be achieved. We will analyze the code piece-by-piece and see what is actually happening in the next section.

Let’s first establish our GoogleMapLoader.vue template:

<template>
  <div>
    <div class="google-map" data-google-map></div>
    <template v-if="Boolean(this.google) && Boolean(this.map)">
      <slot :google="google" :map="map" />
    </template>
  </div>
</template>

Now, our script needs to pass some props to the component which allows us to set the Google Maps API and Map object:

import GoogleMapsApiLoader from "google-maps-api-loader";

export default {
  props: {
    mapConfig: Object,
    apiKey: String
  },
  data() {
    return {
      google: null,
      map: null
    };
  },
  async mounted() {
    const googleMapApi = await GoogleMapsApiLoader({
      apiKey: this.apiKey
    });
    this.google = googleMapApi;
    this.initializeMap();
  },
  methods: {
    initializeMap() {
      const mapContainer = this.$el.querySelector("[data-google-map]");
      this.map = new this.google.maps.Map(mapContainer, this.mapConfig);
    }
  }
};

This is just part of a working example. You can dive in deeper this example.

OK, now that we have our use case set up, let’s move onto breaking that code down to explore what it’s doing.

1. Create a component that initializes our map

In the template, we create a container for the map which will be used to mount the Map object extracted from the Google Maps API.

// GoogleMapLoader.vue
<template>
  <div>
    <div class="google-map" data-google-map></div>
  </div>
</template>

Next up, our script needs to receive props from the parent component which will allow us to set the Google Map. Those props consist of:

  • mapConfig: Google Maps config object
  • apiKey: Our personal api key required by Google Maps
// GoogleMapLoader.vue
import GoogleMapsApiLoader from "google-maps-api-loader";

export default {
  props: {
    mapConfig: Object,
    apiKey: String
  },

Then, we set the initial values of google and map to null:

data() {
  return {
    google: null,
    map: null
  };
},

On the mounted hook, we create an instance of googleMapApi and the map object from it. We also need to set the values of google and map to the created instances:

async mounted() {
  const googleMapApi = await GoogleMapsApiLoader({
    apiKey: this.apiKey
  });
  this.google = googleMapApi;
  this.initializeMap();
},
methods: {
  initializeMap() {
    const mapContainer = this.$el.querySelector("[data-google-map]");
    this.map = new this.google.maps.Map(mapContainer, this.mapConfig);
  }
}
};

So far, so good. With all that done, we could continue adding the other objects to the map (Markers, Polylines, etc.) and use it as an ordinary map component.

But, we want to use our GoogleMapLoader component only as a loader that prepares the map — we don’t want to render anything on it.

To achieve that, we need to allow the parent component that will use our GoogleMapLoader to access this.google and this.map that are set inside the GoogleMapLoader component. That’s where scoped slots really shine. Scoped slots allow us to expose the properties set in a child component to the parent component. It may sound like an inception, but bear with me one more minute as we break that down further.

2. Create component that uses our initializer component

In the template, we render the GoogleMapLoader component and pass props that are required to initialize the map.

// TravelMap.vue
<template>
  <GoogleMapLoader
    :mapConfig="mapConfig"
    apiKey="yourApiKey"
  />
</template>

Our script tag should look like this:

import GoogleMapLoader from "./GoogleMapLoader";
import { mapSettings } from "@/constants/mapSettings";

export default {
  components: {
    GoogleMapLoader,
  },
  computed: {
    mapConfig() {
      return {
        ...mapSettings,
        center: { lat: 0, lng: 0 }
      };
    },
  }
};

Still no scoped slots, so let’s add one.

3. Expose google and map properties to the parent component by adding a scoped slot

Finally, we can add a scoped slot that will do the job and allow us to access the child component props in the parent component. We do that by adding the <slot> tag in the child component and passing the props that we want to expose (using v-bind directive or :propName shorthand). It does not differ from passing the props down to the child component, but doing it in the <slot> tag will reverse the direction of data flow.

// GoogleMapLoader.vue
<template>
  <div>
    <div class="google-map" data-google-map></div>
    <template v-if="Boolean(this.google) && Boolean(this.map)">
      <slot
        :google="google"
        :map="map"
      />
    </template>
  </div>
</template>

Now, when we have the slot in the child component, we need to receive and consume the exposed props in the parent component.

4. Receive exposed props in the parent component using the slot-scope attribute

To receive the props in the parent component, we declare a template element and use the slot-scope attribute. This attribute has access to the object carrying all the props exposed from the child component. We can grab the whole object or we can de-structure that object and only what we need.

Let’s de-structure this thing to get what we need.

// TravelMap.vue
<template>
  <GoogleMapLoader
    :mapConfig="mapConfig"
    apiKey="yourApiKey"
  >
    <template slot-scope="{ google, map }">
      {{ map }}
      {{ google }}
    </template>
  </GoogleMapLoader>
</template>

Even though the google and map props do not exist in the TravelMap scope, the component has access to them and we can use them in the template.

Yeah, OK, but why would I do things like that? What is the use of all that?

Glad you asked! Scoped slots allow us to pass a template to the slot instead of a rendered element. It’s called a scoped slot because it will have access to certain child component data even though the template is rendered in the parent component scope. That gives us a freedom to fill the template with custom content from the parent component.

5. Create factory components for Markers and Polylines

Now, when we have our map ready, we will create two factory components that will be used to add elements to the TravelMap.

// GoogleMapMarker.vue
import { POINT_MARKER_ICON_CONFIG } from "@/constants/mapSettings";

export default {
  props: {
    google: {
      type: Object,
      required: true
    },
    map: {
      type: Object,
      required: true
    },
    marker: {
      type: Object,
      required: true
    }
  },
  mounted() {
    new this.google.maps.Marker({
      position: this.marker.position,
      marker: this.marker,
      map: this.map,
      icon: POINT_MARKER_ICON_CONFIG
    });
  },
};
// GoogleMapLine.vue
import { LINE_PATH_CONFIG } from "@/constants/mapSettings";

export default {
  props: {
    google: {
      type: Object,
      required: true
    },
    map: {
      type: Object,
      required: true
    },
    path: {
      type: Array,
      required: true
    }
  },
  mounted() {
    new this.google.maps.Polyline({
      path: this.path,
      map: this.map,
      ...LINE_PATH_CONFIG
    });
  },
};

Both of these receive google that we use to extract the required object (Marker or Polyline) as well as map which gives as a reference to the map on which we want to place our element.

Each component also expects an extra prop to create a corresponding element. In this case, we have marker and path, respectively.

On the mounted hook, we create an element (Marker/Polyline) and attach it to our map by passing the map property to the object constructor.

There’s still one more step to go...

6. Add elements to the map

Let’s use our factory components to add elements to our map. We must render the factory component and pass the google and map objects so data flows to the right places.

We also need to provide the data that’s required by the element itself. In our case, that’s the marker object with the position of the marker and the path object with Polyline coordinates.

Here we go, integrating the data points directly into the template:

// TravelMap.vue
<template>
  <GoogleMapLoader
    :mapConfig="mapConfig"
    apiKey="yourApiKey"
  >
    <template slot-scope="{ google, map }">
      <GoogleMapMarker
        v-for="marker in markers"
        :key="marker.id"
        :marker="marker"
        :google="google"
        :map="map"
      />
      <GoogleMapLine
        v-for="line in lines"
        :key="line.id"
        :path.sync="line.path"
        :google="google"
        :map="map"
      />
    </template>
  </GoogleMapLoader>
</template>

We need to import the required factory components in our script and set the data that will be passed to the markers and lines:

import { mapSettings } from "@/constants/mapSettings";

export default {
  components: {
    GoogleMapLoader,
    GoogleMapMarker,
    GoogleMapLine
  },
  data() {
    return {
      markers: [
        { id: "a", position: { lat: 3, lng: 101 } },
        { id: "b", position: { lat: 5, lng: 99 } },
        { id: "c", position: { lat: 6, lng: 97 } }
      ],
      lines: [
        { id: "1", path: [{ lat: 3, lng: 101 }, { lat: 5, lng: 99 }] },
        { id: "2", path: [{ lat: 5, lng: 99 }, { lat: 6, lng: 97 }] }
      ]
    };
  },
  computed: {
    mapConfig() {
      return {
        ...mapSettings,
        center: this.mapCenter
      };
    },
    mapCenter() {
      return this.markers[1].position;
    }
  }
};

And we’re done!

With all those bits and pieces completed, we can now re-use the GoogleMapLoader component as a base for all our maps by passing different templates to each one of them. Imagine that you need to create another map with different Markers or just Markers without Polylines. By using a pattern of scoped slots, it becomes very easy since all we need to pass now is different content to the GoogleMapLoader component.

This pattern is not strictly connected to Google Maps; it can be used with any library to set the base component and expose the library’s API that might then be used in the component that summoned the base component.

It might be tempting to create a more complex or robust solution, but this gets us the abstraction we need and it becomes an independent piece of our codebase. If we get to that point, then it might be worth considering extraction to an add-on.