The S in SOLID: Single Responsibility Principle
Creating maintainable and scalable code with the Single Responsibility Principle
Probably you have heard about the SOLID principles, but do you know what they are? In this series of articles, we will explore each of the SOLID principles, starting with the Single Responsibility Principle. We will be using different examples with different frontend technologies to illustrate each principle, so you can understand them better. Let’s get started!
Single Responsibility Principle: The S in SOLID
The Single Responsibility Principle (SRP) is the first principle of the SOLID principles. It states that a class should have only one reason to change. This means that a class should have only one responsibility. If a class has more than one responsibility, it should be refactored into multiple classes, each with a single responsibility.
When we say class
, we mean any unit of code that has a single
responsibility. This could be a class, a function, a module, or any other
unit of code. The key is that the unit of code should have only one
responsibility.
The Single Responsibility Principle is important because it helps to keep code clean, maintainable, and easy to understand. When a class has only one responsibility, it is easier to understand what the class does and how it does it. This makes it easier to maintain and extend the code in the future.
Example: Single Responsibility Principle in Vanilla JavaScript or TypeScript
Let’s look at an example of the Single Responsibility Principle in action. Suppose we have a class that is responsible for formatting and logging messages.
// SOLID - ❌ Bad Example - we format and log the message in the same class
export class Logger {
logMessage(message: string) {
const formattedMessage = `[${new Date().toISOString()}]: ${message}`;
console.log(formattedMessage);
}
}
In this case, even though the class is small, it has two responsibilities: formatting the message and logging the message. To follow the Single Responsibility Principle, we should refactor this class into two separate classes: one for formatting messages and another for logging messages.
// SOLID - ✅ Good Example - we have two classes, each with a single responsibility
class MessageFormatter {
format(message: string): string {
return `[${new Date().toISOString()}]: ${message}`;
}
}
class Logger {
logMessage(message: string) {
const formatter = new MessageFormatter();
console.log(formatter.format(message));
}
}
This is useful for several reasons: we could reuse the MessageFormatter
class in other parts of our codebase, we could easily test the MessageFormatter
class in isolation, and we could change the logging mechanism without affecting the formatting of the message.
Example: Single Responsibility Principle in React
If we want to use SRP in React, we will almost unavoidably need to use custom hooks. Hooks are a great way to separate concerns in React components and follow the Single Responsibility Principle. Let us suppose we have a component that fetches my user data from the GitHub API and displays it.
// User type is imported from another file, not relevant for this example
function UserComponentNoSolid() {
const [user, setUser] = (useState < User) | (null > null)
// ❌ Single Responsibility Principle - LOGIC + UI
useEffect(() => {
fetch('https://api.github.com/users/manuelsanchez2')
.then((res) => res.json())
.then((data) => setUser(data))
}, [])
if (!user) {
return <div>Loading...</div>
}
return (
<div>
<h1>User</h1>
<div>
<img src={user.avatar_url} alt={user.name} />
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
<p>Location: {user.location}</p>
<p>Company: {user.company}</p>
</div>
</div>
)
}
export default UserComponentNoSolid
Since we are violating the Single Responsibility Principle, we should refactor this component into two separate components: one for fetching the user data and another for displaying the user data.
// src/hooks/useUser.ts
import { useEffect, useState } from 'react'
import { User } from '../types'
export function useUser() {
const [user, setUser] = (useState < User) | (null > null)
// ✅ Single Responsibility Principle - LOGIC
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(
'https://api.github.com/users/manuelsanchez2',
)
const data = await response.json()
setUser(data)
}
fetchUser()
}, [])
return user
}
// src/components/UserComponentSolid.tsx
import { User } from '../types'
function UserComponentSolid({ user }: { user: User }) {
// ✅ Single Responsibility Principle - UI
return (
<div>
<h1>User</h1>
<div>
<img src={user.avatar_url} alt={user.name} />
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
<p>Location: {user.location}</p>
<p>Company: {user.company}</p>
</div>
</div>
)
}
export default UserComponentSolid
And then we can use the useUser
hook and the UserComponentSolid
component together.
function App() {
// ✅ Single Responsibility Principle - LOGIC
const user = useUser()
if (!user) {
return <div>Loading...</div>
}
// ✅ Single Responsibility Principle - UI
return (
<>
<UserComponentSolid user={user} />
</>
)
}
export default App
I like to think of the Single Responsibility Principle as the “one thing, one place” principle. Each piece of code should have one responsibility, and that responsibility should be in one place. This makes the code easier to understand, maintain, and extend. Of course, sometimes it can be challenging to follow the Single Responsibility Principle, and even impossible, but in most of the cases it is worth the effort.
Let us take a look at another example that we could easily have when we are dealing with forms. We could have a form that is responsible for both handling the form state and submitting the form. This is a common pattern in React, but it violates the Single Responsibility Principle. We could and maybe should separate the form state logic (input events like onChange, onInput, onBlur, validation, etc) from the form submission logic.
import { useState } from "react";
function FormComponent() {
// ❌ Single Responsibility Principle - Input Initial State + Input Change Logic + Checkbox Initial State + Checkbox Change Logic + Submit Logic
const [input, setInput] = useState('');
const [checkbox, setCheckbox] = useState(false);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
}
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCheckbox(e.target.checked);
}
const handleSubmit = () => {
// Submit form logic
console.log('Form submitted');
};
return (
<form onSubmit={handleSubmit}>
<fieldset>
<legend>User Info</legend>
<div>
<label htmlFor='username'>Name:</label>
<input id='username' name='username' value={input} onChange={(e) => handleInputChange(e)} />
</div>
</fieldset>
<label htmlFor='agree'>
<span>Accept terms:</span>
</label>
<input id='agree' name='agree' type='checkbox' checked={checkbox} onChange={(e) => handleCheckboxChange(e)} />
<button
disabled={!input.value || !checkbox.checked}
type="submit">Submit
</button>
</form>
)
}
export default FormComponent
We could refactor this a bit. You can decide the limits of the Single Responsibility Principle, but in this case at least, we could separate the input state logic from the submit logic using a custom hook.
// src/hooks/useInput.ts
import { useState } from "react";
export function useInput(initialValue: any, type: string = 'text') {
const [value, setValue] = useState(initialValue);
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
if (type === 'checkbox') {
setValue((event.target as HTMLInputElement).checked);
} else {
setValue(event.target.value);
}
};
return {
value,
onChange: handleChange,
...(type === 'checkbox' ? { checked: value } : {}),
};
}
// src/components/FormComponentSolid.tsx
import { useInput } from '../hooks/useInput'
function FormComponentSolid() {
const input = useInput('')
const checkbox = useInput(false, 'checkbox')
const handleSubmit = () => {
// Submit form logic
console.log('Form submitted')
}
return (
<form onSubmit={handleSubmit}>
<fieldset>
<legend>User Info</legend>
<div>
<label htmlFor="username">Name:</label>
<input id="username" name="username" {...input} />
</div>
</fieldset>
<label htmlFor="agree">
<span>Accept terms:</span>
</label>
<input id="agree" name="agree" type="checkbox" {...checkbox} />
<button disabled={!input.value || !checkbox.checked} type="submit">
Submit
</button>
</form>
)
}
export default FormComponentSolid
You can then pretty easily combine what we did today and create a small app that will get the username from a form and pass this string as a query to fetch the information about this user from the GitHub API and display it in a form. We just need to add a few more things to the code we have already written.
// src/App.tsx
import { useState } from 'react'
import './App.css'
import UserComponentSolid from './components/UserComponentSolid'
import { useUser } from './hooks/useUser'
import FormComponentSolidWithQuery from './components/FormComponentSolidWithQuery'
function App() {
const [query, setQuery] = useState('manuelsanchez2')
const user = useUser({ query })
if (!user) {
return <div>Loading...</div>
}
return (
<>
<FormComponentSolidWithQuery setQuery={setQuery} query={query} />
<UserComponentSolid user={user} />
</>
)
}
export default App
// src/components/FormComponentSolidWithQuery.tsx
import { useInput } from '../hooks/useInput';
function FormComponentSolidWithQuery({ query, setQuery }: {
query: string,
setQuery: (query: string) => void
}) {
const input = useInput(query); // this is NEW - we are using the query as the initial value
const checkbox = useInput(false, 'checkbox');
const handleSubmit = (e:
React.FormEvent<HTMLFormElement>
) => {
e.preventDefault();
console.log('Form submitted with valeu', input.value);
setQuery(input.value); // this is NEW - we are setting the query with the input value
};
return (
<form onSubmit={handleSubmit}>
<fieldset>
<legend>User Info</legend>
<div>
<label htmlFor='username'>Name:</label>
<input id='username' name='username' {...input} />
</div>
</fieldset>
<label htmlFor='agree'>
<span>Accept terms:</span>
</label>
<input id='agree' name='agree' type='checkbox' {...checkbox} />
<button
disabled={!input.value || !checkbox.checked}
type="submit">Submit</button>
</form>
);
}
export default FormComponentSolidWithQuery
// src/hooks/useUser.ts
import { useEffect, useState } from "react";
import { User } from "../types";
export function useUser({ query }: { query: string }) {
const [user, setUser] = useState<User | null >(null);
// ✅ Single Responsibility Principle - LOGIC
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(
`https://api.github.com/users/${query}`
); // this is NEW - we are using the query to fetch the user
const data = await response.json();
setUser(data);
}
fetchUser();
}, [query]); // this is NEW - we are using the query as a dependency so we can fetch the user when the query changes, that is, when the form is submitted
return user;
}
Here is a video with the final result:
Conclusion
The Single Responsibility Principle is an important principle of software design that helps to keep code clean, maintainable, and easy to understand. By following the Single Responsibility Principle, you can create code that is easier to maintain, extend, and test. In this article, we explored the Single Responsibility Principle with examples in Vanilla JavaScript/TypeScript, and React. We saw how to refactor code that violates the Single Responsibility Principle into code that follows the principle. I hope this article has helped you understand the Single Responsibility Principle better and how to apply it in your own code. Stay tuned for the next article in this series, where we will explore the Open/Closed Principle.
Check out this simple and clean retro landing page template built with Astro. It includes several pages, dark/light mode, SEO support, newsletter form, and more!
Learn MoreFAQ about Single Responsibility Principle
Share article