Respecting SOLID principles with React
What Are the SOLID Principles?
The acronym S.O.L.I.D was coined by Michael Feathers based on the object-oriented programming principles identified by Robert Cecil Martin AKA Uncle Bob (Clean Code Handbook / Clean Coder).
These principles aim to make code more readable (clean), easy to maintain, easy to evolve, reusable, and free from code duplication.
By "easy," it means that the necessary cost to make a change to the application should always be less than the benefits directly provided by that change.
As developers, by following these principles, we can design robust, flexible, extensible, and easier-to-maintain applications.
Applying these principles significantly contributes to having quality code and ensuring better management of the lifecycle of our applications.
Note:
Understanding the 5 SOLID principles and using them will allow us::
- To improve code quality.
- To understand more evolved codes.
- To reduce the technical debt of our projects.
Using these principles should become a habit in your daily routine.
The "S" for SRP: Single Responsibility Principle
The Single Responsibility Principle states that a class should have only one reason to change, meaning that a class (component, function) should have only one responsibility.
This ensures that a function does only one thing but does it well (cf. SOC: Separation Of Concerns).
Bad Example:
The component manages both display and task management logic.
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const fetchTodos = () => {
fetch('https://api.example.com/todos')
.then(response => response.json())
.then(data => setTodos(data))
.catch(error => console.error(error));
};
useEffect(() => {
fetchTodos(); // Retrieves tasks from an API
}, []);
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
{todo.title}
</div>
))}
</div>
);
};
Good Example:
Divide the component into two:
- One for display
- One for fetching tasks.
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const fetchTodos = () => {
fetch('https://api.example.com/todos')
.then(response => response.json())
.then(data => setTodos(data))
.catch(error => console.error(error));
};
useEffect(() => {
fetchTodos(); // Retrieves tasks from an API
}, []);
return (
<TodoListItems todos={todos} />
);
};
const TodoListItems = ({ todos }) => {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title}
</li>
))}
</ul>
);
};
In the bad example, the TodoListWidget component manages both fetching tasks from an API and displaying tasks.
This violates the Single Responsibility Principle because a component should do only one thing.
In the good example, we have divided the TodoListWidget component into two distinct components: TodoListWidget and TodoListItems.
The TodoListWidget component is responsible for fetching tasks from the API and renders the TodoListItems component, which is responsible for displaying tasks.
The code is clearer, more maintainable, and adheres to this principle—each component has a single responsibility.
We can go even further because the TodoListItems component also violates the Single Responsibility Principle as previously seen. Therefore, it's not 100% correct.
The "O" for OCP: Open/Closed Principle
The Open/Closed Principle advocates that software entities, such as classes and modules, should be open for extension but closed for modification.
A class or module should be extendable to add new functionalities without modifying its existing source code.
This makes it easier to add new methods without disrupting existing behavior.
Simply put.
We should avoid passing objects as parameters when an interface is available. This ensures that the object we are manipulating, regardless of its type, will have the correct associated methods/types.
This requires passing abstractions as parameters, such as contracts through interfaces (for Object-Oriented languages), rather than the objects themselves.
Bad Example:
The TodoListItems component directly handles adding a new task.
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const addTodo = (newTodo) => {
setTodos([...todos, newTodo]);
};
return (
<TodoListItems todos={todos} addTodo={addTodo} />
);
};
Good Example:
Use an external handler function to invert the dependency between TodoListWidget and task retrieval.
const TodoListWidget = ({ fetchCallback, customListItemProvider }) => {
const [todos, setTodos] = useState([]);
const fetchTodos = () => {
// Code to retrieve tasks from an API or elsewhere
const todoItems = fetchCallback(); // call to our injected callback function
setTodos(todoItems);
};
const handleAddTodo = (newTodo) => {
setTodos([...todos, newTodo])
};
const handleDeleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
};
const handleToggleCompleted = (id) => {
setTodos(todos.map(todo => {
if (todo.id === id) {
return {...todo, completed: !todo.completed}
}
return todo
}))
};
useEffect(() => {
fetchTodos()
}, []);
return (
<div>
<h1>Todo List Widget</h1>
<hr/>
<TodoListItems todos={todos}
deleteTodoHandler={handleDeleteTodo}
toggleTodoCompletedHandler={handleToggleCompleted}
customListItemProvider={customListItemProvider}/>
</div>
)
}
export const TodoListItems = ({todos, deleteTodoHandler, toggleTodoCompletedHandler, customListItemProvider}) => {
return (
<div>
{todos.length === 0 && <p className={'text-center'}>No tasks to display</p>}
{todos.length > 0 &&
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{customListItemProvider(todo, deleteTodoHandler, toggleTodoCompletedHandler)}
</li>
))}
</ul>
}
</div>
)
}
export const TodoItem = ({item, deleteTodoHandler, toggleTodoCompletedHandler}) => {
return (
<div>
<input id={`check-${item.id}`} type="checkbox" defaultChecked={item.completed}
onClick={() => toggleTodoCompletedHandler(item.id)}/>
<label htmlFor={`check-${item.id}`}>{item.title}</label>
<button onClick={() => deleteTodoHandler(item.id)}>X</button>
</div>
)
}
And in our App.jsx
import {TodoListWidget} from "./components/Todolist/TodoListWidget.jsx";
import {getData} from "./data/todoListData.js";
import {getData as getDataExt} from "./data/todoListDataExt.js";
import {TodoItemExt} from "./components/Todolist/TodoListExtended/TodoItemExt.jsx";
import {TodoItem} from "./components/Todolist/TodoItem.jsx";
function App() {
const todoItemProvider = (todo, deleteTodoHandler, toggleTodoCompletedHandler) => {
return <TodoItem item={todo}
deleteTodoHandler={deleteTodoHandler}
toggleTodoCompletedHandler={toggleTodoCompletedHandler}/>
}
const todoItemExtProvider = (todo, deleteTodoHandler, toggleTodoCompletedHandler) => {
return <TodoItemExt item={todo}
deleteTodoHandler={deleteTodoHandler}
toggleTodoCompletedHandler={toggleTodoCompletedHandler}/>
}
return (
<div>
<div">
<TodoListWidget fetchCallback={getData} customListItemProvider={todoItemProvider}/>
<TodoListWidget fetchCallback={getDataExt} customListItemProvider={todoItemExtProvider}/>
</div>
</div>
)
}
export default App
You may have noticed in the code above, I removed the addTodoHandler prop from the TodoListItems component. Why? As mentioned earlier, the Single Responsibility Principle was being violated. It's not the role of the TodoListItems component to add a task to our data. Its sole role is to display the list of tasks.
Therefore, we need to create a new TodoAddForm component and use it in the TodoListWidget component to continue adhering to the SOLID principles.
I'll let you make this small change yourself.
The Complete Code
Find a complete example of this TodoListWidget respecting the SOLID principles on my Github: react-solid-todolist
To Go Further
If you want to delve deeper into this area, check out our React JS training!