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();
});

👉 Github Code