React — Uncouples, Injects and Reverses Dependencies
The idea is uncoupling, injection and reversing dependencies in React, using context, services, and repositories.
(spanish) La idea es desacoplar y lograr una inyección e inversión de dependencias en React, usando contextos, servicios y repositorios.
Let’s go!
Create the Project
yarn create react-app todo-app --template typescript
yarn add styled-components @types/styled-components
Clean and create folders:
src/services
src/repositories
src/contexts
src/providers
src/components
src/__tests__
Create the Repositories and Services
src/repositories/todo.repository.ts
abstract class TodoRepository {
abstract add(todo: string): void;
abstract getAll(): string[];
abstract delete(todo: string): void;
protected validateExistTodo(todos: string[], todo: string): boolean {
const tempTodos = todos.map((todo) => todo.toLocaleLowerCase());
return tempTodos.includes(todo.toLocaleLowerCase());
}
}
export default TodoRepository;
src/repositories/todoInMemory.repository.ts
import TodoRepository from './todo.repository';
class TodoInMemoryRepository extends TodoRepository {
private todos: string[] = [];
public add(todo: string) {
if (!this.validateExistTodo(this.todos, todo)) {
this.todos.unshift(todo);
}
}
public delete(todo: string) {
if (this.validateExistTodo(this.todos, todo)) {
this.todos = this.todos.filter(
(_todo) => _todo.toLocaleLowerCase() !== todo.toLocaleLowerCase(),
);
}
}
public getAll(): string[] {
return this.todos;
}
}
export default TodoInMemoryRepository;
src/repositories/todoInLocalStorage.repository.ts
import TodoRepository from './todo.repository';
class TodoInLocalStorageRepository extends TodoRepository {
private todos: string[] = [];
private key: string = 'todos';
public constructor() {
super();
this.getFromLocalStorage();
}
private getFromLocalStorage() {
const tempTodos = window.localStorage.getItem(this.key);
this.todos = tempTodos === null ? [] : JSON.parse(tempTodos);
}
private insertToLocalStorage() {
window.localStorage.setItem(this.key, JSON.stringify(this.todos));
}
public add(todo: string) {
if (!this.validateExistTodo(this.todos, todo)) {
this.todos.unshift(todo);
this.insertToLocalStorage();
}
}
public delete(todo: string) {
if (this.validateExistTodo(this.todos, todo)) {
this.todos = this.todos.filter(
(_todo) => _todo.toLocaleLowerCase() !== todo.toLocaleLowerCase(),
);
this.insertToLocalStorage();
}
}
public getAll(): string[] {
return this.todos;
}
}
export default TodoInLocalStorageRepository;
src/services/todo.service.ts
import TodoRepository from '../repositories/todo.repository';
class TodoService {
private repository: TodoRepository;
public constructor(repository: TodoRepository) {
this.repository = repository;
}
public getAll(): string[] {
return this.repository.getAll();
}
public add(todo: string): void {
this.repository.add(todo);
}
public delete(todo: string): void {
this.repository.delete(todo);
}
}
export default TodoService;
Watch how we inject the abstract repository and not the implementation.
Create the Context
src/contexts/todos.context.ts
import { createContext } from 'react';
type TypeTodosContext = {
todos: string[];
add: (todo: string) => void;
delete: (todo: string) => void;
};
const InitialTodosContext: TypeTodosContext = {
todos: [],
add: () => {},
delete: () => {},
};
const TodosContext = createContext<TypeTodosContext>(InitialTodosContext);
export default TodosContext;
Write the Components
Todo.tsx
import React, { useContext } from 'react';
import styled from 'styled-components';
import TodosContext from '../contexts/todos.context';
const TodoContainer = styled.div`
display: flex;
justify-content: space-between;
padding: 20px;
background-color: #f0f0f0;
margin: 10px 0;
`;
type TodoProps = {
name: string;
};
const Todo: React.FC<TodoProps> = ({ name }): JSX.Element => {
const ctx = useContext(TodosContext);
const handlerClick = () => {
ctx.delete(name);
};
return (
<TodoContainer>
<span>{name}</span>
<button onClick={handlerClick}>❌</button>
</TodoContainer>
);
};
export default Todo;
TodoList.tsx
import React, { useContext } from 'react';
import styled from 'styled-components';
import TodosContext from '../contexts/todos.context';
import Todo from './Todo';
const Ul = styled.ul`
list-style: none;
`;
const TodoList: React.FC = (): JSX.Element => {
const { todos } = useContext(TodosContext);
return (
<Ul>
{todos.map((todo: string, index: number) => (
<li key={index}>
<Todo name={todo} />
</li>
))}
</Ul>
);
};
export default TodoList;
TodoForm.tsx
import React, { SyntheticEvent, useContext, useRef } from 'react';
import styled from 'styled-components';
import TodosContext from '../contexts/todos.context';
const Form = styled.form`
display: flex;
justify-content: center;
`;
const Input = styled.input`
padding: 10px;
&[type='submit'] {
cursor: pointer;
}
`;
const TodoForm = (): JSX.Element => {
const inputText = useRef<HTMLInputElement>(null);
const { add } = useContext(TodosContext);
const handleSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
if (inputText.current !== null) {
add(inputText.current.value || '');
inputText.current.value = '';
}
};
return (
<Form onSubmit={handleSubmit}>
<Input ref={inputText} type='text' name='todo' placeholder='Add todo' required />
<Input type='submit' value='ADD' />
</Form>
);
};
export default TodoForm;
TodoApp.tsx
import React from 'react';
import styled from 'styled-components';
import TodoForm from './TodoForm';
import TodoList from './TodoList';
const Div = styled.div`
max-width: 800px;
margin: 0 auto;
`;
const TodoApp = (): JSX.Element => {
return (
<Div>
<TodoForm />
<TodoList />
</Div>
);
};
export default TodoApp;
Write the Provider
src/providers/todos.provider.tsx
import React, { ReactNode, useEffect, useState } from 'react';
import TodosContext from '../contexts/todos.context';
import TodoService from '../services/todo.service';
type TypeTodoProvider = {
service: TodoService;
children?: ReactNode;
};
const TodosProvider: React.FC<TypeTodoProvider> = ({ service, children }): JSX.Element => {
const [todos, setTodos] = useState<string[]>([]);
useEffect(() => {
getAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getAll = (): void => {
setTodos([...service.getAll()]);
};
const add = (todo: string): void => {
service.add(todo);
getAll();
};
const _delete = (todo: string): void => {
service.delete(todo);
getAll();
};
const contextValue = {
todos,
add,
delete: _delete,
};
return <TodosContext.Provider value={contextValue}>{children}</TodosContext.Provider>;
};
export default TodosProvider;
Setup in index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import TodoApp from './components/TodoApp';
import TodosProvider from './providers/todos.provider';
import reportWebVitals from './reportWebVitals';
import TodoInMemoryRepository from './repositories/todoInMemory.repository';
import TodoService from './services/todo.service';
const repository = new TodoInMemoryRepository();
const todoService = new TodoService(repository);
ReactDOM.render(
<React.StrictMode>
<TodosProvider service={todoService}>
<TodoApp />
</TodosProvider>
</React.StrictMode>,
document.getElementById('root'),
);
reportWebVitals();
Testing
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import TodoService from '../services/todo.service';
import TodoApp from '../components/TodoApp';
import TodosProvider from '../providers/todos.provider';
import TodoInMemoryRepository from '../repositories/todoInMemory.repository';
const renderApp = (service: TodoService) => {
return render(
<TodosProvider service={service}>
<TodoApp />
</TodosProvider>,
);
};
test('should add todo in todo app', () => {
const repository = new TodoInMemoryRepository();
const service = new TodoService(repository);
const { container } = renderApp(service);
const textInput = screen.getByPlaceholderText(/add todo/i);
const button = container.querySelectorAll('input[type=submit]')[0];
const todo = 'test me';
fireEvent.change(textInput, { target: { value: todo } });
fireEvent.click(button);
expect(screen.getByText(todo)).toBeInTheDocument();
});