The simplicity of building components with React is what makes it hard to understand for some people. The idea behind component-based architecture is to create modular and reusable user interfaces, but this is not a rule when building React applications, since the library doesn't force you to follow these principles.
React lets you create components in the way that you want, which is great but can also lead to a codebase that is hard to understand and maintain. Today I'll show you my approach to apply one of the SOLID principles, the SRP (Single-Responsibility principle), to component-building and as a result, have a cleaner and easy-to-test codebase.
What is the SRP?
The Single-Reponsibility principle, also known as SRP, is the first SOLID principle. Those principles were created by Uncle Bob for object-oriented programming, to establish software engineering practices to help maintain and extend the codebase as the project grows. Adopting these practices can also contribute to avoiding code smells, easier testing, and less refactoring.
The SRP on its definition means:
A class should have one, and only one reason to change, meaning that a class should have only one job.
This is quite simple to understand, right? Every class, function, and component, should have a very clear and defined purpose. But wait... React don't use OOP principles. It also doesn't apply or reinforce any OOP mechanism (inheritance, polymorphism, etc.). Even when using class-based components, it doesn't behave as an OOP library (e.g., you cannot call a method from one class on another).
So how are we going to apply SRP to our React code?
Applying SRP to React
You cannot apply the SRP by the book on React, but you can apply de idea behind the principle to your applications.
This is when the concept of layers comes in place. Looking at components with the layers perspective was a game-changer to me when I started learning React. My approach is to modularize not only the component, but also the features and shared logic:
- Utilities layer: the utils, containing short and specific functions to build bigger things with;
- Feature layer: the hooks, containing reusable features e.g., fetching data, handling forms;
- Presentation layer: the components, as specific as possible.
Note that this approach has nothing to do with the layered architecture, clean architecture or any other application-wise modularization. This is more like a way to build reusable and clean components, which you can use along any architectural pattern that you choose.
With all that in mind, let's jump to the code!
The example application
For our examples, let's progressively build a simple UserCards
component, which receives user data, shows a loading state, and then renders it on the screen on some pretty cards.
For the sake of simplicity, I'm not showing any CSS code or the content of fetchUsers()
, but you can assume that it's an asynchronous function that returns all the users.
If you want to see the entire code, check my GitHub repository. Each commit represents one step of this article. At the end of each section you'll find the link for the respective commit.
Final result:
The code:
// src/components/UserCards.jsx
const UserCards = () => {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
fetchUsers()
.then((data) => {
setUsers(data);
})
.catch((error) => {
console.error(error);
})
.finally(() => {
setIsLoading(false);
});
}, []);
const getAgeFromBirthdate = (birthdate) => {
const birthday = +new Date(birthdate);
return ~~((Date.now() - birthday) / 31557600000);
};
const handleCallClick = (phoneNumber) => {
window.open(`tel:${phoneNumber}`);
};
const handleSendEmailClick = (email) => {
window.open(`mailto:${email}`);
};
return (
<section className="section">
{isLoading ? (
<svg
aria-hidden="true"
className="w-16 h-16 text-gray-200 animate-spin fill-teal-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
) : (
users.map((user) => (
<div key={user.id} className="user-card">
<img src={user.profilePictureUrl} className="profile-picture" />
<span className="name">{user.name}</span>
<div className="text-wrapper">
<span className="role">{user.role}</span>
<span className="age">
{getAgeFromBirthdate(user.birthdate)} years old
</span>
</div>
<div className="actions-wrapper">
<button
className="button-ghost"
onClick={() => handleCallClick(user.phoneNumber)}
>
Call me
</button>
<button
className="button-primary"
onClick={() => handleSendEmailClick(user.email)}
>
Send email
</button>
</div>
</div>
))
)}
</section>
);
};
Well, that's some messy code! Let's improve it, step by step.
The Utilities Layer
You can notice that some of the functions and behaviors that UserCards
has, aren't only related to it:
getAgeFromBirthdate()
window.open(tel:xxxx)
window.open(mailto:xxxx)
These behaviors can be extracted as utils, so they can be reused across other places of our app that require the same functionality. Let's create these util files:
// src/utils/date.js
export const getAgeFromBirthdate = (birthdate) => {
const birthday = +new Date(birthdate);
return ~~((Date.now() - birthday) / 31557600000);
};
// src/utils/contact.js
export const openCallPhoneNumber = (phoneNumber) => {
window.open(`tel:${phoneNumber}`);
};
export const openSendEmail = (email) => {
window.open(`mailto:${email}`);
};
Great! We just extracted some functionality that was not restricted to the component, and now other components can make use of it without needing to re-create. Another huge advantage is that we can now test each util function individually, instead of only testing the component as a whole. This makes the application more reliable and decoupled.
Now we need to import the utils into the UserCards
component, and replace the old function calls with the new ones. The component will look like this:
// src/components/UserCards.jsx
// Utils imports
import { getAgeFromBirthdate } from "../utils/date";
import { openCallPhoneNumber, openSendEmail } from "../utils/contact";
const UserCards = () => {
// ...
const handleCallClick = (phoneNumber) => {
openCallPhoneNumber(phoneNumber); // Calling the util
};
const handleSendEmailClick = (email) => {
openSendEmail(email); // Calling the util
};
return (
<section className="section">
{/* ... */}
<div className="text-wrapper">
<span className="role">{user.role}</span>
<span className="age">
{getAgeFromBirthdate(user.birthdate) /* Calling the util */}
years old
</span>
</div>
{/* .. */}
</section>
);
};
The Features Layer
The only feature that we have in our example is the one that gets the user, sets it on the state and also manages the load state according to the status of the request. Let's break the "fetch users" feature:
- When the component loads, the process of getting the users starts on the
useEffect
; - The
isLoading
state needs to be set totrue
to give feedback to the user; - The function will make an asynchronous request to the API to fetch the data;
- If the call succeeds, we set the data to the state (
setUsers(data)
); - Otherwise, we just print the error on the console;
- In both cases, at the end, we'll change the
isLoading
state tofalse
to show the data on the UI.
This behavior is represented on the following code:
// src/components/UserCards.jsx
const UserCards = () => {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true); // Sets loading state
fetchUsers() // API call
.then((data) => {
setUsers(data); // Success: persist data to the state
})
.catch((error) => {
console.error(error); // Print error on the console
})
.finally(() => {
setIsLoading(false); // Remove loading state
});
}, []);
// ...
};
There's nothing wrong with this code. Although, if we need to fetch users on other components, this logic will be duplicated. This is the point where things start to get messy, because this logic will be coupled to each component, making it hard to test (same case as the utils) and also maintain. If the "fetch users" logic changes, you'll need to change it in all the components that you're calling it.
The solution to this issue is abstract the logic into a hook. If a component needs to "fetch users", it just needs to call the hook and that's it. So let's create this hook:
// src/hooks/useFetchUsers.js
export const useFetchUsers = () => {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
fetchUsers()
.then((data) => {
setUsers(data);
})
.catch((error) => {
console.error(error);
})
.finally(() => {
setIsLoading(false);
});
}, []);
return { users, isLoading };
};
It's basically copy-paste from the code that we already had. Now let's refactor the UserCards
component:
// src/components/UsersCard.jsx
// Hook import
import { useFetchUsers } from "../hooks/useFetchUsers";
const UserCards = () => {
const { users, isLoading } = useFetchUsers(); // Hook call
const handleCallClick = (phoneNumber) => {
openCallPhoneNumber(phoneNumber);
};
const handleSendEmailClick = (email) => {
openSendEmail(email);
};
// ...
};
Much cleaner, right? Now you have all the advantages of decoupling, reusability and also a better readability.
The Presentation Layer
Last but not least, let's improve our JSX code. There are some elements that can be extracted from this component:
- Loading component (SVG);
- User card (the code inside the map);
- The buttons.
// src/components/UserCards.jsx
const UserCards = () => {
// ...
return (
<section className="section">
{isLoading ? (
// Loading component
<svg>{/* ... */}</svg>
) : (
users.map((user) => (
// User card
<div key={user.id} className="user-card">
{/* ... */}
<div className="actions-wrapper">
{/* Buttons */}
<button
className="button-ghost"
onClick={() => handleCallClick(user.phoneNumber)}
>
Call me
</button>
<button
className="button-primary"
onClick={() => handleSendEmailClick(user.email)}
>
Send email
</button>
</div>
</div>
))
)}
</section>
);
};
Let's abstract these components:
// src/components/Loading.jsx
const Loading = () => (
<svg
aria-hidden="true"
className="w-16 h-16 text-gray-200 animate-spin fill-teal-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
);
// src/components/Button.jsx
const Button = ({ text, onClick, variant }) => {
const variantClassName = {
primary: "button-primary",
ghost: "button-ghost",
}[variant || "primary"];
return (
<button className={variantClassName} onClick={onClick}>
{text}
</button>
);
};
// src/components/UserCard.jsx
import { getAgeFromBirthdate } from "../utils/date";
import { openCallPhoneNumber, openSendEmail } from "../utils/contact";
import { Button } from "./Button";
const UserCard = ({ user }) => (
<div className="user-card">
<img src={user.profilePictureUrl} className="profile-picture" />
<span className="name">{user.name}</span>
<div className="text-wrapper">
<span className="role">{user.role}</span>
<span className="age">
{getAgeFromBirthdate(user.birthdate)} years old
</span>
</div>
<div className="actions-wrapper">
<Button
variant="ghost"
text="Call me"
onClick={() => openCallPhoneNumber(user.phoneNumber)}
/>
<Button text="Send email" onClick={() => openSendEmail(user.email)} />
</div>
</div>
);
And here's our final UserCards
component:
// src/components/UserCards.jsx
import { Loading } from "./Loading";
import { UserCard } from "./UserCard";
const UserCards = () => {
const { users, isLoading } = useFetchUsers();
return (
<section className="section">
{isLoading ? (
<Loading />
) : (
users.map((user) => <UserCard key={user.id} user={user} />)
)}
</section>
);
};
Like we've seen before, this improves the testability, readability, and also decouples the components as well.
Conclusion
My goal here was to show you my approach to component-building, and also give you some ideas for improving your own process. This is not in any kind a rule or the best approach ever to solve the problems that I'd show you.
Always keep in mind that the best process is the one that fits your needs and the needs of your team.
If you have any feedback or suggestions, send me an email: lucashwolff@gmail.com.
Great coding!
Top comments (0)