When we talk or discuss about creating UI components in Vue.js, reusability is often brought up. Yes, one of the key principles of Vue.js is its component-based architecture, which promotes reusability and modularity. But what does that even mean?
Let's say you create a reusable component:
- Can you or your colleagues really reuse (no pun intended) it in another part of the system?
- With a new requirement, you may have to consider modifying the "reusable component".
- What if you need to split the "reusable component" to so that you can apply the split component to another place?
Creating actually reusable components in Vue.js can be tricky. In this article, I will explore the concept of reusable components, the problems faced when applying them, and why it is essential to overcome these problems as best as I can.
Vue-PDF-Viewer: Flexible and Powerful Vue.js PDF Component
Just a quick background about what Iโm working on. Vue-PDF-Viewer renders the PDF viewer on your Vue or Nuxt websites so that your users can interact with your PDF document without leaving your sites. The component has over 20 features including theme customization, built-in localization, web responsive and more.
I would be grateful if you could check Vue-PDF-Viewer out. It will encourage me to make even more contents ๐
What are Reusable Components?
Reusable components are UI building blocks that can be used in different parts of an application or even across multiple projects. They encapsulate specific functionality or UI patterns and can be easily integrated into other parts of the application without the need for extensive modifications.
Benefits of Reusable Components
By using Reusable Components in Vue.js, you can achieve several benefits like:
- Efficiency: Allow developers to write code once and reuse it multiple times. This reduces redundancy and saves valuable development time.
- Standardization: Promote consistency and standardization across a Vue.js project. They ensure that the same design patterns, styles, and functionality are maintained throughout the application.
- Scalability: Make it easier to scale and adapt projects as they grow. By breaking down the application into smaller, reusable components, it becomes more manageable to handle complex functionalities and add new features.
- Collaboration: Facilitate collaboration among team members working on a Vue.js project. They provide a shared vocabulary and set of UI elements that everyone in the team can use and understand.
3 Problems when Applying Reusable Concept
While reusability is a desirable trait in Vue.js components, several problems can make it difficult to achieve:
Modifying Existing Components: One problem is modifying existing components that are already being used in the application. The component may need to be changed to support both existing and new requirements. Making changes to a component that is already used in other parts of the application may introduce unintended side effects and break functionality in other areas. Balancing the need for changes with maintaining compatibility can be complex.
Design Components for Consistency and Flexibility: Another problem is maintaining consistency across different instances of a reusable component while allowing for customization and flexibility. Reusable components should be versatile enough to adapt to different design requirements and styles. However, providing customization options without sacrificing the core functionality and consistency of the component can be tricky.
Managing Component Dependencies and State: Using reusable components involves managing dependencies and ensuring that each component remains self-contained and independent. Components should not have tight dependencies on external resources or the application's state management system. This allows for easy integration into different projects and reduces the likelihood of conflicts or unintended side effects.
A Case Study
Let's say, a client wants an internal employee directory system. The project is engaged based on Agile methodology and all requirements cannot be gathered before development. There are 3 phases (Prototype, Phase 1 and Phase 2). For the purpose of this demonstration, I will focus on a card component like below:
Prototype
As part of the prototype phase, I am required to deliver a User Profile page. The user profile will contain a basic user card component to include user avatar and name.
// Prototype.vue
<script setup lang="ts">
import { defineProps, computed, Teleport, ref } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
}
const props = defineProps<Props>();
</script>
<template>
<div class="app-card">
<img
class="user-image"
:src="image"
alt="avatar" />
<div>
<div>
<label> {{ firstName }} {{ lastName }} </label>
</div>
</div>
</div>
</template>
<style scoped>
.app-card {
padding-left: 10px;
padding-right: 10px;
padding-top: 5px;
padding-bottom: 5px;
background: white;
box-shadow: 0 0 5px;
border-radius: 5px;
border: none;
font-size: 1.5em;
transition: 0.3s;
display: flex;
align-items: center;
}
.app-card label {
font-weight: 600;
}
.app-card:hover {
background: rgba(128, 128, 128, 0.5);
}
.user-image {
width: 100px;
}
</style>
Phase 1
In Phase 1, the client wants to add user detail (birthday, age, phone number and email) onto the user card component.
//Phase1.vue
<script setup lang="ts">
import { defineProps, computed } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
birthDay?: string;
phone?: string;
email?: string;
}
const props = defineProps<Props>();
const age = computed(() => {
if (!props.birthDay) {
return "0";
}
const birthYear = new Date(props.birthDay).getFullYear();
const currentYear = new Date().getFullYear();
return currentYear - birthYear;
});
</script>
<template>
<div
ref="cardRef"
class="app-card">
<img
class="user-image"
:src="image"
alt="avatar" />
<div>
<div>
<label> {{ firstName }} {{ lastName }} </label>
</div>
<div>
<div>
<label> Birth day: </label>
<span>
{{ birthDay }}
</span>
</div>
<div>
<label> Age: </label>
<span>
{{ age }}
</span>
</div>
<div>
<label> Phone number: </label>
<span>
{{ phone }}
</span>
</div>
<div>
<label> Email: </label>
<span>
{{ email }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.app-card {
padding-left: 10px;
padding-right: 10px;
padding-top: 5px;
padding-bottom: 5px;
background: white;
box-shadow: 0 0 5px;
border-radius: 5px;
border: none;
font-size: 1.5em;
transition: 0.3s;
display: flex;
align-items: center;
}
.app-card label {
font-weight: 600;
}
.app-card:hover {
background: rgba(128, 128, 128, 0.5);
color: black;
}
.user-image {
width: 100px;
}
</style>
Additionally, the client is looking to add Employee Directory page and display user profiles in a card format.
// SearchPage
<template>
<div>
<SearchInput v-model:value="searchValue" />
<template
:key="item.id"
v-for="item of list">
<div style="margin-bottom: 5px; margin-top: 5px">
<UserCard v-bind="item" />
</div>
</template>
</div>
</template>
<script lang="ts">
import SearchInput from "../components/SearchInput.vue";
import UserCard from "../components/Phase1.vue";
import { ref, watch } from "vue";
export default {
name: "Search",
components: {
SearchInput,
UserCard,
},
setup() {
const searchValue = ref<string>();
const list = ref();
fetch("https://dummyjson.com/users")
.then((res) => res.json())
.then((res) => (list.value = res.users));
watch(searchValue, (v) => {
fetch(`https://dummyjson.com/users/search?q=${v}`)
.then((res) => res.json())
.then((res) => (list.value = res.users));
});
watch(list, (v) => console.log(v));
return {
searchValue,
list,
};
},
};
</script>
At this stage, the user card component is reusable on both pages.
Phase 2
Users feedback that the Employee Directory page is cluttered. Too much information making the page hard to use. Therefore, the client wants the user detail to be shown in tooltip upon hovered. The requirement on the User Setting page remains unchanged.
// Phase 2
<script setup lang="ts">
import {
defineProps,
computed,
Teleport,
ref,
onMounted,
onBeforeUnmount,
} from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
birthDate?: string;
phone?: string;
email?: string;
address?: string;
}
const props = defineProps<Props>();
const targetRef = ref<HTMLDiveElement>();
const isMouseOver = ref(false);
const dropdownRef = ref<HTMLDivElement>();
const dropdownStyle = ref({});
// add modal element in body to prevent overflow issue
const modalElement = document.createElement("div");
modalElement.id = "modal";
document.body.appendChild(modalElement);
const age = computed(() => {
if (!props.birthDate) {
return "0";
}
const birthYear = new Date(props.birthDate).getFullYear();
const currentYear = new Date().getFullYear();
return currentYear - birthYear;
});
const onMouseOver = () => {
if (isMouseOver.value) {
return;
}
isMouseOver.value = true;
const dimension = targetRef.value.getBoundingClientRect();
dropdownStyle.value = {
width: `${dimension.width}px`,
left: `${dimension.x}px`,
top: `${window.scrollY + dimension.y + dimension.height + 5}px`,
};
};
const onMouseLeave = () => {
isMouseOver.value = false;
};
</script>
<template>
<div
ref="targetRef"
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
class="app-card"
>
<img class="user-image" :src="image" alt="avatar" />
<div>
<div>
<label> {{ firstName }} {{ lastName }} </label>
</div>
</div>
</div>
<Teleport to="#modal">
<div
ref="dropdownRef"
:style="dropdownStyle"
style="position: absolute"
v-show="isMouseOver"
>
<div class="app-card">
<div>
<div>
<label> Birth day: </label>
<span>
{{ birthDate }}
</span>
</div>
<div>
<label> Age: </label>
<span>
{{ age }}
</span>
</div>
<div>
<label> Phone number: </label>
<span>
{{ phone }}
</span>
</div>
<div>
<label> Email: </label>
<span>
{{ email }}
</span>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.app-card {
padding-left: 10px;
padding-right: 10px;
padding-top: 5px;
padding-bottom: 5px;
background: white;
box-shadow: 0 0 5px;
border-radius: 5px;
border: none;
font-size: 1.5em;
transition: 0.3s;
display: flex;
align-items: center;
}
.app-card label {
font-weight: 600;
}
.app-card:hover {
background: rgba(128, 128, 128, 0.5);
color: black;
}
.user-image {
width: 100px;
}
</style>
To look at the codes of Prototype, Phase 1 and Phase 2, you can find them in live demo.
This new requirement causes a headache:
- Do I modify the existing user card component to support the tooltip requirement and risk affecting the user card component in the User Setting page? OR
- Do I duplicate the existing user card component and add tooltip feature?
Because we do not want to break what is already in production, we tend to choose the latter option. At first, this may make sense but it can be quite damaging, especially for big and continuous projects:
- Large Codebase: Leads to a larger codebase, as each duplicated component adds unnecessary lines of code. Become difficult to maintain, as developers need to make changes in multiple places whenever an update or bug fix is required. It also increases the chances of inconsistencies.
- Short Term Gain, Long Term Pain: Seem like a quick and easy solution in the short term, especially when dealing with tight deadlines or urgent requirements. However, as your project grows, maintaining duplicated components becomes increasingly difficult and time-consuming. Modifications or updates to duplicated components need to be replicated across multiple instances, leading to a higher chance of errors.
- System Performance: Can negatively impact system performance. Redundant code increases the size of the application, leading to slower rendering times and increased memory usage. This can result in a suboptimal user experience and reduced system efficiency.
How to Overcome the Above Problems
It's to be mentally prepared that the reusable components may not always remain the same throughout your project. It may sound cliche but if you think about it, requirements are always evolving. You cannot control the future except do the best you can at the moment. Of course, experiences help you designing better components but it takes time.
Refactor Reusable Components
From my experience, I will redesign and refactor the reusable components. Refactoring is a process to restructure codes, while not changing its original functionality. I'm sure there are many ways to refactor and, for me, I refactor and breakdown the components into smaller components. Smaller components allow for flexibility in applying them across the system. Let's take a look how I will apply the case study mentioned above.
Note: It takes discipline to refactoring UI components. Also, it can be challenging at times since you will need to balance with project delivery deadlines and cleaner codes.
Apply Solution to the Case Study
First, I will split the existing user card components into 4 components:
- Card component
- Avatar component
- Name component
- User detail component
// Card.vue
<template>
<div class="app-card">
<slot></slot>
</div>
</template>
<style scoped>
.app-card {
padding-left: 15px;
padding-right: 15px;
padding-top: 10px;
padding-bottom: 10px;
border-radius: 5px;
border: none;
background: white;
color: black;
font-size: 1.5em;
transition: 0.3s;
display: flex;
align-items: center;
box-shadow: 0 0 5px;
}
.app-card:hover {
background: rgba(128, 128, 128, 0.5);
color: black;
}
</style>
// Avatar.vue
<script setup lang="ts">
import { defineProps } from "vue";
interface Props {
image: string;
}
const props = defineProps<Props>();
</script>
<template>
<img
class="user-image"
:src="image"
alt="avatar" />
</template>
<style scoped>
.user-image {
width: 100px;
}
</style>
// UserName.vue
<script setup lang="ts">
import { defineProps } from "vue";
interface Props {
firstName: string;
lastName: string;
}
const props = defineProps<Props>();
</script>
<template>
<label> {{ firstName }} {{ lastName }} </label>
</template>
// Description Item
<script setup lang="ts">
import { defineProps } from "vue";
interface Props {
label: string;
value: string | number;
}
const props = defineProps<Props>();
</script>
<template>
<div>
<label> {{ label }}: </label>
<span>
{{ value }}
</span>
</div>
</template>
<style scoped>
label {
font-weight: 600;
}
</style>
// UserDescription.vue
<script setup lang="ts">
import DescriptionItem from "./DescriptionItem.vue";
import { defineProps, computed } from "vue";
interface Props {
birthDate: string;
phone: string;
email: string;
}
const props = defineProps<Props>();
const age = computed(() => {
if (!props.birthDate) {
return "0";
}
const birthYear = new Date(props.birthDate).getFullYear();
const currentYear = new Date().getFullYear();
return currentYear - birthYear;
});
</script>
<template>
<div>
<DescriptionItem
label="Birth day"
:value="birthDate" />
<DescriptionItem
label="Age"
:value="age" />
<DescriptionItem
label="Phone number"
:value="phone" />
<DescriptionItem
label="Email"
:value="email" />
</div>
</template>
After that, I will create a tooltip component. Creating a separate tooltip allows me to reuse it in other parts of the system.
// Tooltip.vue
<script setup lang="ts">
import {
Teleport,
computed,
ref,
onMounted,
onBeforeUnmount,
watch,
} from "vue";
const isMouseOver = ref(false);
const targetRef = ref<HTMLDivElement>();
const dropdownStyle = ref({});
const dropdownRef = ref<HTMLDivElement>();
const existModalElement = document.getElementById("modal");
if (!existModalElement) {
// add modal element in body to prevent overflow issue
const modalElement = document.createElement("div");
modalElement.id = "modal";
document.body.appendChild(modalElement);
}
const onMouseOver = () => {
if (isMouseOver.value) {
return;
}
isMouseOver.value = true;
const dimension = targetRef.value.getBoundingClientRect();
dropdownStyle.value = {
width: `${dimension.width}px`,
left: `${dimension.x}px`,
top: `${window.scrollY + dimension.y + dimension.height + 5}px`,
};
};
const onMouseLeave = () => {
isMouseOver.value = false;
};
</script>
<template>
<div @mouseover="onMouseOver" @mouseleave="onMouseLeave" ref="targetRef">
<slot name="default" />
</div>
<Teleport to="#modal">
<div
ref="dropdownRef"
:style="dropdownStyle"
style="position: absolute"
v-show="isMouseOver"
>
<Card>
<slot name="overlay" />
</Card>
</div>
</Teleport>
</template>
Finally, I will combine the components together like below
For the User Setting page, I will use the user card component which consists of card, avatar, name component and user detail components.
// UserWithDescription.vue
<script setup lang="ts">
import AppCard from "./Card.vue";
import DescriptionItem from "./DescriptionItem.vue";
import Avatar from "./Avatar.vue";
import UserName from "./UserName.vue";
import UserDescription from "./UserDescription.vue";
import { defineProps } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
birthDate?: string;
phone?: string;
email?: string;
address?: string;
}
const props = defineProps<Props>();
</script>
<template>
<AppCard>
<Avatar :image="image" />
<div>
<div>
<UserName :firstName="firstName" :lastName="lastName" />
</div>
<UserDescription v-bind="props" />
</div>
</AppCard>
</template>
As for the Employee Directory page, I plan for 2 combined components
- The basic user card component which consists of card, avatar and name components.
- The user tooltip component which consists of card, tooltip and user detail components.
// UserCard.vue
<script setup lang="ts">
import AppCard from "./Card.vue";
import DescriptionItem from "./DescriptionItem.vue";
import Avatar from "./Avatar.vue";
import UserName from "./UserName.vue";
import { defineProps } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
}
const props = defineProps<Props>();
</script>
<template>
<AppCard>
<Avatar :image="image" />
<div>
<div>
<UserName
:firstName="firstName"
:lastName="lastName" />
</div>
</div>
</AppCard>
</template>
// UserCardWithTooltip.vue
<script setup lang="ts">
import ToolTip from "./Tooltip.vue";
import UserDescription from "./UserDescription.vue";
import UserCard from "./UserCard.vue";
import Card from "./Card.vue";
import { defineProps } from "vue";
interface Props {
firstName: string;
lastName: string;
image?: string;
birthDate?: string;
phone?: string;
email?: string;
}
const props = defineProps<Props>();
</script>
<template>
<ToolTip>
<UserCard v-bind="props" />
<template #overlay>
<Card>
<UserDescription v-bind="props" />
</Card>
</template>
</ToolTip>
</template>
For more details on the refactored codes of this case study, please check out the solution.
Note: You may notice that the solution provide is based on Atomic Design concept. The concept can minimize the "reusability" challenge in the first place. If you are interested on how it can apply to Vue.js, please see my colleague's article.
Do Unit Tests Help?
Some may think that writing unit tests for reusable components will ease this problem. It's true comprehensive test coverage helps ensure that modifications and enhancements to components do not accidentally break functionality.
However, unit tests do not make a component more reusable. It just makes it more robust. In fact, refactoring into smaller components breaks tasks into specific pieces and makes writing unit tests more manageable.
Conclusion
Creating actual reusable components in Vue.js can be challenging due to issues related to modifying existing components, maintaining consistency, and managing dependencies and state. However, the benefits of reusable components make it worth overcoming these problems. Reusable components enhance code organization, improve development efficiency, and facilitate the creation of consistent user interfaces. As we face new requirements or tasks, we will improve so that we can better ourselves at designing reusable components.
Check Out Vue PDF Viewer for Your Next Project
If youโre working with Vue.js and need a PDF viewer thatโs easy to integrate and highly customizable, you should check out Vue PDF Viewer. It allows you to display PDFs directly within your Vue or Nuxt apps, with over 20 features including theme customization, localization, and responsive design.
Iโd really appreciate it if you could give Vue PDF Viewer a try and let me know what you think! Your support will help me keep creating more useful tools and content ๐๐ฅ
Top comments (0)