How this pattern made me write reusable Vue components with little effort
A long time ago, when I started to learn Vue.js, I reached out to people who were experienced in the framework to ask for tips and get directions on what to learn and how to structure code. One tip that stuck with me was: "Always separate the logic from the presentational component”. I thought, well that seems reasonable, I'll try that. But to be honest, at that time, I did not actually understand what they meant specifically. So, with time, I wrote more and more Vue code, and got to write large SPAs until I finally understood why people told me to separate logic from the presentational layer, and most importantly, I found a great pattern that solves that problem.
Let's say you have a Vue SFC (single file component) which sends a request to the server, stores the items in state (like VueX or Pinia) and then renders a list of items. More experienced developers might already see the problem just by reading that sentence, but I took me some time. I started to have problems writing components like this because when you create unit tests for these components you have to mock so many things: the request to the server, the response, the state... the same thing happens if you want to add the component to a component library like Storybook. It's a pain. Now, if the component only take the list of items to render, render them and emit events, your life becomes a lot easier because you can test only the interface of the component i.e. if it renders x when you pass x as a prop and it emits event y when you interact in some way with the component.
The same thing happens with the logic you took away from the presentational component. When you want to test it, you can test just the bits that are important to the logic. You don't need to worry about how or who is going to render what. You just care about the functions and the flow of data.
Did I peak your interest? Ok, great! Let's see some code. I created a repo with a starter project so that you can follow along, you can clone the repo here.
To reach the point of that starter project you can run npm init vue@latest
and when asked to “Add Pinia for state management” you select "Yes". After it finishes, go to the project directory and install the dependencies (npm install
) and then you can run the project (npm run dev
). Even though this is a Vue 3 example, I already applied this same pattern in Vue 2 and it works the same (and it will probably work the same in any other frontend framework).
If you cloned the repository, open it in your code editor and you should see a very simple example:
// src/components/PetList.vue
<script setup>
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue';
import { usePets } from '../stores/pets'
const store = usePets();
const { pets } = storeToRefs(store)
onMounted(() => {
store.fetchCats();
})
const emit = defineEmits(['click']);
function handleClick(index) {
emit('click', index);
}
defineExpose({
pets,
handleClick
})
</script>
<template>
<ul>
<li v-for="(pet, i) in pets">
<button @click="handleClick(i)">
<img :src="pet" />
</button>
</li>
</ul>
</template>
First, don't worry about the template style or anything like that. Let's focus on the script tag first:
the App.vue
file only renders a PetList
component and this Vue component basically renders an image list, but in the script part, it sends a request to an API through Pinia when the component is mounted, which returns a list of cat images and uses the state to get this list and finally expose them to the template to render.
This is the store:
// src/stores/pets.js
import { defineStore } from 'pinia';
export const usePets = defineStore('pets', {
state: () => {
return {
pets: [],
};
},
actions: {
async fetchCats() {
try {
const cats = await fetch(
'<https://api.thecatapi.com/v1/images/search?limit=10&order=DESC>'
).then(res => res.json());
this.pets = cats.map(cat => cat.url);
} catch (e) {
//
}
},
}
})
What happens if later the business wants to render another list but this time with images of dogs?
Well, one option would be to copy the entire component, replace the logic to fetch and render dogs and call it a day. You still would have problems to test the component and now if something changes in the template, you have to change in both components. You won't forget it, right?
Another option would be to pass a prop to the component which works as a flag: catOrDog
and based on this prop you could use if/else everywhere to render cat or dog images. You keep the template in one place but honestly the script might becomes very messy and full of “this vs that” paths.
Enter "Container/Presentational pattern" 🎉
As its name suggest, this pattern will allow us to separate the presentational component from the business logic, encouraging separation of concerns. The container can fetch, handle and modify the data that will be shown to the user. Their responsibility is to pass that data (which they contain) to a presentational component. The presentational component will actually display (present) that data to the user.
Let's refactor this code to use containers for the logic and keep the presentational component (the template) very clean and with a clear interface of props and events:
// src/components/PetList.vue
<script setup>
const props = defineProps({
pets: {
type: Array,
default: () => [],
}
});
const emit = defineEmits(['click']);
function handleClick(index) {
emit('click', index);
}
defineExpose({
pets: props.pets,
handleClick
})
</script>
<template>
<ul class="pet-list">
<li v-for="(pet, i) in pets">
<button @click="handleClick(i)" class="pet-list__button">
<img :src="pet" class="pet-list__img" />
</button>
</li>
</ul>
</template>
Notice that the template part almost didn't change. The script part on the other hand is a lot simpler: you define props and events that the component might emit and that's it. If your pass cat images it will render cat images, if you pass dog images it will render dog images. If in the future the business decides to render a list for an entire zoo, you're covered. Component unit tests become a breeze and you can add the component to Storybook or other component libraries very easily.
Now, let's create a container for the CatList:
// src/components/CatList.container.js
import { storeToRefs } from 'pinia';
import { onMounted, h } from 'vue';
import { usePets } from '../stores/pets';
import PetList from './PetList.vue';
export default {
setup() {
const store = usePets();
const { cats } = storeToRefs(store);
onMounted(() => {
store.fetchCats();
});
function handleOnClick(index) {
console.log('Clicked on cat picture #' + index);
}
return () =>
h(PetList, {
pets: cats.value,
onClick: handleOnClick,
});
},
};
This container is responsible for fetching the cat images when it is mounted, get the images from state and render the PetList SFC component passing the list as a prop. It also listens for events that the PetList component might emit and handles them.
I like to use a convention for naming containers: [Name of the component].container.js
so I know what to expect when I'm working on these files.
Now, in your App.vue
, instead of importing PetList.vue
you will have to import CatList.container.js
and render that just like you render a regular Vue component. For any purposes, it is a regular Vue component, but instead of rendering a template, it returns an h
function which actually renders the component.
Now, let's create the DogList container:
// src/components/DogList.container.js
import { storeToRefs } from 'pinia';
import { onMounted, h } from 'vue';
import { usePets } from '../stores/pets';
import PetList from './PetList.vue';
export default {
setup() {
const store = usePets();
const { dogs } = storeToRefs(store);
onMounted(() => {
store.fetchDogs();
});
function handleOnClick(index) {
console.log('Clicked on dog picture #' + index);
}
return () =>
h(PetList, {
pets: dogs.value,
onClick: handleOnClick,
});
},
};
Notice that this almost identical to CatList.container
but instead of fetchCats
it calls fetchDogs
in the onMounted
hook. This is just a simple example, in the wild the logic inside these containers can vary a lot but the important part is that your PetList.vue
is still the same. It still accepts the same props and as long as other containers respect this interface, it should render a nice list of images.
That's it for today! I hope that with this article you now have a better understanding of this pattern and can take a "shortcut" with this knowledge so you don't go into the same pitfalls I went :)