cft

SOLID Frontend Components

Single Responsibility Principle


user

Simon Msara

3 years ago | 12 min read

S.O.L.I.D is an acronym that represents the first five principles that should be followed when writing object oriented code. These 5 principles were declared by Robert C. Martin aka Uncle Bob.

These principles were established with classes, interfaces and functions in mind but I will try and extrapolate their core values to fit modern frontend development.

We will focus on VueJS components in this article but these extrapolations can be applied to other frontend frameworks.

Single Responsibility Principle

A component should have only one job. Think of a component the same way you do an HTML element, you don’t expect a button to act like a table, right?Components you create should have a clear purpose, whose names declare intention.

Keeping a single purpose in mind when creating a component will allow for a simple and maintainable structure. The moment we start considering multiple and unrelated contexts that the component can be used in, we lose the usefulness of splitting our code into components.

A component’s complexity grows exponentially with every new context that’s added.

The correct usage of components is just as important as their definition. If a component is used in the wrong way it will lead to difficulty in understanding the code and even allow for the pollution of components with unnecessary and ill-fitting contexts down the line.

Open-Close Principle

A component should be open for extension but closed for modification. We should favour extension rather than altering the component and adding more complexity.

The key take away here should be to delegate contextual complexity rather than accommodating all possibilities within a single component.

There are a few ways of extending a component:

  • child components — transfer complexity to child components that can be controlled with the use of props and the ‘is’ attribute
  • slots — allow the parent component to be in charge of rendering the unpredictable content
  • extension components — components that inherit the original component’s functionality and behaviour without affecting the extended component
  • all-encompassing components — (most common) these are components that inherit all the possible components and render them accordingly. These are similar to extension components but don’t have any specific functionality, they behave more like guards, deciding which components are rendered.

The child components extension pattern involves registering child components with the extended behaviour and dynamically rendering the specified one based on passed props.

If the number of contexts is large & ever growing, there are some clever ways of automatically registering components, that way we don’t have to hard code what components are used, therefore removing maintenance load.

This pattern of extension is best served when the child components do not share the same component structure but have a similar API.

The example below shows how we could control which child component is rendered using props and automatic component registration. We are registering all breed components in the dog-breeds folder and controlling which component is rendered using props and the ‘is’ attribute.

<template>
<div>
<img :src="avatar" alt="dog avatar" />
<span>{{name}}</span>

<div v-if="breed" :is="breed" />
</div>
</template>

<script>
const requireContext = require.context('@/dog-breeds', false, /.*\.vue$/);

const Breeds = requireContext.keys()
.map(file =>
[file.replace(/(^.\/)|(\.vue$)/g, ''), requireContext(file)]
)
.reduce((components, [name, component]) => {
components[name] = component.default || component
return components
}, {});

export default {
name: 'dog-profile',
components: {...Breeds},
props: {
avatar: {
required: false,
type: String,
default: '/default-dog-avatar.jpg',
},
name: {
required: true,
type: String,
},
breed: {
required: false,
type: Object,
}
},
// ...
}
</script>

Slots are great for situations where there is no way of knowing what is going to be rendered but want to make space for it. The example below shows how we can implement the dog breed change using the slot pattern.

The major difference is that we are shifting import/maintenance responsibility from the dog-profile component to the parent component.

<template>
<div>
<img :src="avatar" alt="dog avatar" />
<span>{{name}}</span>

<slot name="breed"></slot>
</div>
</template>

<script>
export default {
name: 'dog-profile',
props: {
avatar: {
required: false,
type: String,
default: '/default-dog-avatar.jpg',
},
name: {
required: true,
type: String,
},
},
// ...
}
</script>

The extension components pattern leaves the extended component pure and adds the necessary changes usually with only one specific use case. Strictly speaking, this is the most appropriate way of extending a component.

This pattern is usually used when the extension doesn’t have a lot of contextual variations but is very complicated in nature.

For example if we would like to have components for the following scenario, a dog profile can also can have a breed section, a biology section or both and lets assume all of these sections are quite complicated; we could have the following components that are used in the most appropriate cases:

<template>
<div>
<img :src="avatar" alt="dog avatar" />
<span>{{name}}</span>
</div>
</template>

<script>
export default {
name: 'dog-profile',
props: {
avatar: {
required: false,
type: String,
default: '/default-dog-avatar.jpg',
},
name: {
required: true,
type: String,
},
},
// ...
}
</script>

<template>
<dog-profile :avatar="avatar" :name="name"/>

<div v-if="breed">
<h4>{{breed.name}}</h4>
<img :src="breed.avatar" alt="breed photo">
<p>{{breed.description}}</p>
</div>
</template>

<script>
import DogProfile from "dog-profile";
export default {
name: "dog-profile-with-breed",
components: {
DogProfile
},
props: {
avatar: {
required: false,
type: String
},
name: {
required: true,
type: String,
},
breed: {
required: false,
type: Object,
},
},
// ...
}
</script>

<template>
<dog-profile-with-breed :avatar="avatar" :name="name"/>

<div v-if="biology">
<!-- Some complicated stuff -->
</div>
</template>

<script>
import DogProfile from "dog-profile";
export default {
name: "dog-profile-with-breed-and-biology",
components: {
DogProfile
},
props: {
avatar: {
required: false,
type: String
},
name: {
required: true,
type: String,
},
breed: {
required: false,
type: Object,
},
biology: {
required: false,
type: Object
}
},
// ...
}
</script>

As mentioned above, all encompassing components are similar to extension components so we could refactor the extension components by isolating their unique functionality and importing them into a single dog component that renders them dynamically based on props. This is by far the most common practice.

<template>
<dog-profile :avatar="avatar" :name="name"/>

<dog-breed v-if="breed" :breed="breed"/>

<dog-biology v-if="biology" :biology="biology"/>
</template>

<script>
import DogProfile from "dog-profile";
import DogBreed from "dog-breed";
import DogBiology from "dog-biology";
export default {
name: "dog-profile-with-breed-and-biology",
components: {
DogProfile,
DogBreed,
DogBiology,
},
props: {
avatar: {
required: false,
type: String
},
name: {
required: true,
type: String,
},
breed: {
required: false,
type: Object,
},
biology: {
required: false,
type: Object
}
},
// ...
}
</script>

Liskov Substitution Principle

This one requires a bit more imagination 😂! When looking at it from the context of classes and interfaces, we should still be able to use the extended class/interface in the same way as we would the original class/interface.

In our case we do not want to lose the functionality and behaviour from the original component when we use the extended component.

Sticking with our dog profile example, if we use any of the component extension techniques, we should still expect to have access to the original component’s functionality when we use the extended version.

Therefore, passing the same props to both the extension and original components should render the same result. This means that extension components’ props should include & extend the original component’s props making additional props nullable and relay the original component’s events.

If this rule is not satisfied, we cannot define the ‘extending component’ as one because by definition it’s not extending, it’s redefining — it is its own type of component.

Interface Segregation Principle

Only the props required for the component to render correctly should be made mandatory and only the essential parts of the component should be rendered.

For example if a component renders a profile and the avatar is not always going to be required, the user of the profile component should not be forced to have an avatar, its rendering should be controllable.

Dependency Inversion Principle

A component should only depend on abstraction not concretions — it should not depend on the props’ values but rather their types.

For example, if a profile component renders an avatar it should rely on a valid but not specific url being passed, using a specific URL does not make the component reusable, however, a default may be defined if no URL has been passed.

For example the dog-profile component will not be of much use if it always renders the same avatar.

This article was originally published by Simon msara on medium.

Upvote


user
Created by

Simon Msara

PHP, Laravel, TypeScript VueJs


people
Post

Upvote

Downvote

Comment

Bookmark

Share


Related Articles