In one of my recent experiences, you often would find components such as the example below:
import React, { useState, useEffect } from "react";
import { TodoApiImpl } from "../../TodoApi/TodoApi";
import './TodoOldStyle.css'
const api = new TodoApiImpl();
const TodoOldStyle = () => {
const [todos, setTodos] = useState<any[]>([]);
const [newTodoText, setNewTodoText] = useState("");
useEffect(() => {
const fetchTodos = async () => {
const fetchedTodos = await api.getTodos();
setTodos(fetchedTodos);
};
fetchTodos();
}, []);
const addTodo = async () => {
if (!newTodoText.trim()) return;
const newTodo = await api.addTodo(newTodoText);
setTodos([...todos, newTodo]);
setNewTodoText("");
};
const toggleTodo = async (id: number) => {
const toggledTodo = await api.toggleTodo(id);
setTodos(todos.map(todo => (todo.id === id ? toggledTodo : todo)));
};
const deleteTodo = async (id: number) => {
await api.deleteTodo(id);
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div className="todo-container">
<h1>Todo List</h1>
<div className="todo-input">
<input
type="text"
placeholder="Add a new todo"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
/>
<button onClick={addTodo}>Add</button>
</div>
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className={`todo-item ${todo.completed ? "completed" : ""}`}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
</label>
<button className="delete-button" onClick={() => deleteTodo(todo.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
);
};
export default TodoOldStyle;
That was making me really frustrated, as our code was not easy to test, the dev experience was not great, and the maintenance was starting to become a nightmare.
At that point, I started to look for alternatives, of how could we have a better code, improving the cohesion, coupling, and testability of our application, while also improving the Developer Experience.
The solution that I found? To start using MVVM.
What is MVVM?
If you're not familiar with MVVM (Model-View-ViewModel), it is an architectural pattern for frontend and mobile development that was created initially by John Gossman at Microsoft.
Its main objective is to provide a better separation of concerns, which then provides an increase in the cohesion, coupling, and testability of the applications.
This pattern breaks down the application into three key components:
Model: The data layer, that represents the application's core data and business logic. It is responsible for fetching, storing, and manipulating data.
View: The user interface, is responsible for presenting data to the user. It listens for user input and forwards events to the ViewModel.
ViewModel: The intermediary between the View and Model. It retrieves data from the Model, processes it, and exposes it to the View. The ViewModel also handles user interactions by updating the Model and instructing the View on updating the UI.
For a better understanding, using the same Todo Example, the application would then have, a model, a ViewModel, a View, and an Entrypoint for the application.
So how does that look like on the code:
Step 1: Define the Model
This Model, will eventually be injected over the view model, and would be injected over the view model and serve as the business logic + data manager.
// Todo/model/TodoModel.tsx.
import { TodoApi } from "../../../TodoApi/TodoApi";
export interface ITodoModel {
getTodos(): Promise<Todo[]>;
addTodo(text: string): Promise<Todo>;
toggleTodo(id: number): Promise<Todo>;
deleteTodo(id: number): Promise<void>;
}
export default class TodoModel implements ITodoModel {
constructor(private todoApi: TodoApi) {}
async getTodos() {
return await this.todoApi.getTodos();
}
async addTodo(text: string) {
return await this.todoApi.addTodo(text);
}
async toggleTodo(id: number) {
return await this.todoApi.toggleTodo(id);
}
async deleteTodo(id: number) {
return await this.todoApi.deleteTodo(id);
}
}
Step 2: Create the ViewModel
The ViewModel acts as the moderator between the View and the Model. For better testability, use DI pattern, allowing the Model to be injected over the ViewModel, and keep the state management here, so the View would only be responsible for rendering the UI.
// Todo/view-model/TodoViewModel.tsx.
import { useEffect, useState } from "react";
import { Todo } from "../../../TodoApi/TodoApi";
import TodoModel from "../model/TodoModel";
export const useTodoViewModel = (model: ITodoModel) => {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodoText, setNewTodoText] = useState("");
const fetchTodos = async () => {
const fetchedTodos = await model.getTodos();
setTodos(fetchedTodos);
};
const addTodo = async () => {
if (!newTodoText.trim()) return;
const newTodo = await model.addTodo(newTodoText);
setTodos([...todos, newTodo]);
setNewTodoText("");
};
const toggleTodo = async (id: number) => {
const toggledTodo = await model.toggleTodo(id);
setTodos(todos.map((todo) => (todo.id === id ? toggledTodo : todo)));
};
const deleteTodo = async (id: number) => {
await model.deleteTodo(id);
setTodos(todos.filter((todo) => todo.id !== id));
};
const onNewTodoTextChange = (text: string) => setNewTodoText(text);
useEffect(() => {
fetchTodos();
}, [])
return {
todos,
newTodoText,
addTodo,
toggleTodo,
deleteTodo,
fetchTodos,
onNewTodoTextChange
};
};
Step 3: Create the View
For the sake of simplicity, add todo, and list of todos are in the same component, but in an actual application, please divide it.
As stated before, the View should only receive the props and render it accordingly keeping the logic, and state management away of it.
// Todo/view/TodoView.tsx.
import React from "react";
import "./TodoView.css";
import { Todo } from "../../../TodoApi/TodoApi";
interface TodoViewProps {
todos: Todo[];
toggleTodo: (id: number) => void;
deleteTodo: (id: number) => void;
addTodo: () => void;
newTodoText: string;
onNewTodoTextChange: (text: string) => void;
}
export const TodoView: React.FC<TodoViewProps> = ({
todos,
toggleTodo,
deleteTodo,
addTodo,
onNewTodoTextChange,
newTodoText,
}) => {
return (
<div className="todo-ctainer">
<h1>Todo List</h1>
<div className="todo-input">
<input
type="text"
placeholder="Add a new todo"
value={newTodoText}
onChange={(e) => onNewTodoTextChange(e.target.value)}
/>
<button onClick={addTodo}>Add</button>
</div>
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className={`todo-item ${todo.completed ? "completed" : ""}`}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
</label>
<button className="delete-button" data-testid={`delete-button-${todo.id}`} onClick={() => deleteTodo(todo.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
);
};
Step 4: Orchestrate it all
Here that would be the component rendered via router, or imported over others.
This component, orchestrates the injection of the Model over the ViewModel, the API over the Model, and connects the model with the View.
// Todo/App.tsx.
import { TodoApiImpl } from "../../TodoApi/TodoApi";
import TodoModel from "./model/TodoModel";
import { useTodoViewModel } from "./view-model/TodoViewModel";
import { TodoView } from "./view/TodoView";
const api = new TodoApiImpl();
const model = new TodoModel(api);
export const Todo = () => {
const result = useTodoViewModel(model);
return <TodoView {...result} />;
};
Conclusion
While the MVVM pattern adds complexity upfront, due to setting up the additional layers, the long-term benefits have a huge payback over the initial investment, as with this architecture, you achieve low coupling with high cohesion, separation of concerns, better testability, and maintainability.
It helps you to build scalable, testable, and maintainable applications that can evolve with ease.
Whether you're working on a small app or a large enterprise project, adopting patterns like MVVM will help you maintain a clean architecture and improve the overall development experience.
Top comments (0)