Front-end tools like React, Angular, and Vue provide us with a component-based architecture. We can build complex user interfaces from smaller pieces called components which are easier to manage. If we design our components properly, we can take advantage of the benefits of components. One of those benefits is reusability.
When building components, they should be decoupled, focused on a single responsibility, and well-tested. This can be difficult when we hurry to meet deadlines.
We should avoid doing the following:
- building big components with many responsibilities
- building tightly coupled components
- building components with no unit tests.
Avoiding these bad practices will help us reduce technical debt. Once technical debt accumulates, it makes it hard to modify existing functionalities or create new functionalities.
Good components should apply the following principles:
- Single Responsibility
- Loose Coupling
The single-responsibility principle (SRP) is a programming principle that states that every module, class or function in an application should have responsibility over only a single part of that application's functionality.
For web components, this means that a component has a single responsibility when it implements one responsibility, or when it does one thing. This gives the component only one reason to change.
Some examples of a component's responsibility could be to render a list of items, to make a HTTP request, or to draw a chart. A component should pick only one responsibility and implement it. When modifying the way a component implements its responsibility, it has only one reason to change. If the component's responsibility is to render a list of items, then it's one reason to change could be when the limit to the number of items rendered needs to change.
When a component has only one responsibility, it makes it easier to code, modify, reuse, and test. The size of the component is reduced and the component is given a laser focus.
Components with many responsibilities should be split by individual responsibility into separate components.
When a component has multiple responsibilities, it is fragile, and has many reasons to change. This is risky because changing the component for one reason can affect how other responsibilities are handled by that same component. We want to avoid unintentional side effects because they are hard to predict and handle. We want the modification of our component to happen in isolation so that it doesn't inadvertently affect anything else.
The worst case scenario of building a component with multiple responsibilities is creating a God component. This is an anti-pattern (a counterproductive pattern). God components tend to know too much and do too much. God components should be broken down by their individual responsibilities into separate easy to manage components.
Coupling defines the degree of dependency between two or more components. Loose coupling happens when an application's components have very little or no knowledge about other components.
On the contrary, tight coupling happens when an application's components know many details about each other and depend on each other. Tight coupling makes it difficult to modify a component because it is highly dependent on other components. Just one small change in one component could break several other components or require that they also be updated. Therefore, we want the relationships between our components to be loosely coupled.
The benefits of loose coupling are:
- Making changes to one component does not affect the other.
- Components can be replaced without impacting other components.
- Components are reusable across the application.
- Testing is made easier.
A components should not know about or rely on another component's internal implementation details. Components that are well encapsulated hide their internal structure from other components. This isolates the component. However, components still need to communicate with each other. To accomplish this in React, we can provide props to change the behavior of a component. To accomplish this in Angular, we can provide inputs. Inputs and props should be as plain and raw as possible.
An example of broken encapsulation is when a component's internal structure spreads across an application. If a child component is permitted to directly update the state of the parent component, encapsulation is broken. This is because the child component knows too many details about its parent.
What details does the child know about its parent in this example?
- The child knows that its parent is a stateful component.
- The child knows the parent's state object structure.
- The child knows how to update the parent's state.
Composition refers to combining components to create a bigger component. Composition is like playing with Legos. It refers to taking a set of small pieces, combining them, and creating something bigger.
The Single Responsibility Principle we saw earlier described how to split responsibilities into components. The Encapsulation Principle that we just saw described how to organize components in isolation. The Composition Principle describes how to glue the whole system of components back together.
An example is having an
<App> component that has had its responsibilities divided into four sub-responsibilities, creating four new components:
Composition then glues back
<App> from these four specific components. The
<App> component becomes responsible for rendering the
<Sidebar> components. Composition has helped us to make the
<App> component adhere to the Single Responsibility Principle by allowing each of its children implement sub-responsibilities.
Composition has helped us build our application with a very clean and simple organization. Components that are built with composition in mind are able to reuse common logic. This helps us to achieve the next principle, reusability.
The Reusability Principle allows us to write a component once and use it many times. We want to make things work and reuse their functionality, not reinvent how they work.
The Don't repeat yourself (DRY) principle can help us achieve reusability. It states that every piece of information should have one single representation within an application.
A lack of reusable components will make the application harder to maintain without providing any benefits. Without reusability in place, a piece of application logic will not be self-contained in one place. This means that an update to that logic will mean updates to many parts of the application.
Components that have only one responsibility are the most reusable ones. However, when a component has many responsibilities, reusing it becomes complicated. You may want to reuse only one of its responsibilities, but you will be forced to accept all of its other responsibilities along with what you need.
Part of having a good component architecture involves properly categorizing components. Our application should make a distinction between Smart and Dumb components, or Container and View components. Smart or Container components focus on how things work. Dumb or View components focus on how things look.
- Manipulate data: fetching, manipulating, and passing down data.
- Are stateful: managing application state (Redux, etc) and re-rendering needs.
- Do not include styling.
- Focus solely on the styling and presentation of elements.
- Accept inputs/props to allow them to be dynamic and reusable.
- Have no dependencies.
- Have no application state.
Examples of dumb components are inputs, modals, buttons, dropdowns, etc. A dumb component may have local state for handling user interactions, but it will not know about the state of the application's data.
By making this distinction between smart and dumb components evident in how we name them and organize them within our file hierarchy, we will have a better designed application. Presentation responsibilities will be organized in dumb components. Stateful and business logic responsibilities will be organized in smart components.
A testable component is one that is easy to test. We should test that a component returns the expected output for a given input. We should aim for component purity because it makes components easier it is to test. A pure component is like a pure function, it will always return the same same output for the same input.
It's very difficult and time-consuming to manually verify that each component is working as expected every time we make a change. Bugs are inevitable without proper unit tests for each component. Unit tests allow us to automate the tedious task of component testing. Every time we modify a component, we can re-run it's unit tests to verify that they still pass.
Unit tests don't only help us reduce bugs, they also help us to have a good component architecture. If a component in our application is not testable or difficult to test, it means that the component has not been designed properly.
One of the main reasons that developers do not write tests for their components is because their components are not testable. When a component does not adhere to the principles mentioned in this article, testing it becomes very difficult and frustrating. As a result, the developer gives up writing unit tests for it.
A readable component is one in which it is easy to understand what it does. A readable component has readable code within that component. This allows developrs to quickly understand the responsibility of a component when looking at its code.
- Aim for clarity over brevity when naming things.
- Name components by their responsibility.
- Include code comments in components.
When naming components, be specific. Consider what their responsibility is. Instead of
In conclusion, the eight principles for a good component architecture that we covered are: single responsibility, loose coupling, encapsulation, composition, reusability, testability, categorization, and readability. By applying these principles when designing components, you can ensure a robust and effective component architecture in your applications.