Идея заключается в развязывании, внедрении и изменении зависимостей в реакции с использованием контекста, сервисов и репозиториев.

(испанский) La idea es desacoplar y lograr una inyeccion e inversion de dependecias en react, usando contextos, servicios y repositorios.

Пойдем!

создать проект

yarn create react-app todo-app --template typescript
yarn add styled-components  @types/styled-components

очистить и создать папки:

src/services, src/repositories, src/contexts, src/providers, src/components, src/__tests__

Создание репозиториев и сервисов

источник/репозитории/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;

источник/репозитории/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;

следите за тем, чтобы в конструкции мы внедряли абстрактный репозиторий, а не реализацию.

Создайте контекст

источник/контексты/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;

Напишите компоненты

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;

Что ж, все компоненты у нас есть, теперь пишем провайдер для инжекта сервиса, репозиторий и определяем обработчики контекста.

Напишите поставщика

src/provides/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;

вот и все, перейдите в 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")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Посмотрите, как мы определяем службу и передаем ее провайдеру с помощью реквизитов. Службе нужен репозиторий, но у него может быть несколько реализаций, которые не зависят от службы.

если мы изменим репозиторий, мы не пострадаем от изменений в коде.

Тестирование:

const repository = new TodoInLocalStorageRepository();
const todoService = new TodoService(repository);

да все работает!.

теперь можно запускать приложение с localStorage и тестировать с данными в памяти без дополнительного кода или проблем.

Напишите тест:

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

-› Код на Гитхабе