План

  • создать проект с помощью приложения create-реагировать и установить зависимости
  • удалить ненужные файлы и код
  • создать магазин с редуксом и сделать его доступным в нашем приложении
  • создавать типы
  • добавить некоторые действия в наш магазин и управлять состоянием
  • создавать компоненты и отправлять действия

Создайте проект реакции и установите зависимости

Перейдите в папку вашего проекта, откройте терминал и запустите

npx create-react-app . --template typescript

Теперь установите зависимости

npm i redux react-redux @types/react-redux redux-devtools-extension bulma @fortawesome/fontawesome-free

Поскольку мы будем использовать typescript, нам нужно установить типы для пакета react-redux. Нам также нужно установить bulma, потому что мы будем использовать bulma css framework для пользовательского интерфейса и fontawesome для использования иконок.

Удалить ненужные файлы и код

Удалите файлы App.test.tsx, logo.svg, index.css, serviceWorker.ts и setupTests.ts.

Удалите все из файла App.css. Удалите импорт логотипа из файла App.tsx и всю разметку внутри div с классом App, пока просто оставьте этот div.

И из файла index.tsx удалите импорт serviceWorker и код serviceWorker и импорт index.css.

Теперь App.tsx должен выглядеть так:

import React from 'react';
import './App.css';
function App() {
  return (
    <div className="App">
      
    </div>
  );
}
export default App;

index.tsx:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

Создайте магазин с редуксом и сделайте его доступным в нашем приложении.

Внутри папки src создайте папку хранилища, а внутри папки хранилища — папки действий и редукторов, а также файлы store.ts и types.ts.

Внутри папки редукторов создайте 2 файла, listReducer.ts иnotificationReducer.ts.

А пока добавьте этот код в оба этих файла:

export default (state, action) => {
  switch(action.type) {
    default:
      return state;
  }
}

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

Теперь давайте перейдем к файлу store.ts и добавим внутрь этот код:

import { createStore, combineReducers } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import listReducer from './reducers/listReducer';
import notificationReducer from './reducers/notificationReducer';
const rootReducer = combineReducers({
  list: listReducer,
  notification: notificationReducer
});
const store = createStore(rootReducer, composeWithDevTools());
export type RootState = ReturnType<typeof rootReducer>;
export default store;

Здесь нам нужно объединить два наших редуктора и создать хранилище с помощью функции createStore. Я также использую расширение redux devtools, чтобы проверить в своем браузере, какие типы были отправлены.

Нам также нужно экспортировать тип RootState, который является типом rootReducer, чтобы использовать его в наших компонентах всякий раз, когда мы будем использовать хук useSelector.

И, наконец, мы можем экспортировать наш магазин по умолчанию.

Теперь магазин создан, и теперь нам нужно сделать его доступным в нашем приложении.

Теперь давайте вернемся к файлу index.tsx и импортируем Provider из react-redux, обернем им наше приложение и добавим наш магазин в качестве реквизита. Нам также нужно импортировать наш магазин, и поскольку мы обновляем этот файл, мы также можем импортировать bulma и fontawesome.

index.tsx должен выглядеть так:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import '../node_modules/bulma/css/bulma.min.css';
import '../node_modules/@fortawesome/fontawesome-free/css/all.min.css';
import store from './store/store';
import App from './App';
ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

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

Перейдите к файлу types.ts в папке магазина и добавьте следующие типы:

export const GET_LISTS = 'GET_LISTS';
export const GET_LIST_BY_ID = 'GET_LIST_BY_ID';
export const ADD_LIST = 'ADD_LIST';
export const DELETE_LIST = 'DELETE_LIST';
export const UPDATE_LIST = 'UPDATE_LIST';
export const SET_LISTID_TO_DELETE = 'SET_LISTID_TO_DELETE';
export const SET_LIST_TO_EDIT = 'SET_LISTID_TO_EDIT';
export const SET_SELECTED_LIST = 'SET_SELECTED_LIST';
export const ADD_TASK = 'ADD_TASK';
export const DELETE_TASK = 'DELETE_TASK';
export const SET_TASK_TO_DELETE = 'SET_TASK_TO_DELETE';
export const UNSET_TASK_TO_DELETE = 'UNSET_TASK_TO_DELETE';
export const UPDATE_TASK = 'UPDATE_TASK';
export const SET_TASK_TO_EDIT = 'SET_TASK_TO_EDIT';
export const UNSET_TASK_TO_EDIT = 'UNSET_TASK_TO_EDIT';
export const SET_NOTIFICATION = 'SET_NOTIFICATION';

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

Давайте создадим несколько интерфейсов для наших задач и списков. Добавьте их ниже типов магазинов.

export interface Task {
  name: string;
  id: string;
  completed: boolean;
}
export interface List {
  name: string;
  id: string;
  tasks: Task[];
}
export interface Lists {
  [id: string]: List
}

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

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

Мы будем хранить наши списки в локальном хранилище как объект, который будет иметь ключи (идентификатор списка) и значения (фактический список). Поэтому для этого нам нужен интерфейс Lists. id будет динамическим, поэтому нам нужно использовать [].

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

// Actions
interface AddListAction {
  type: typeof ADD_LIST;
  payload: List;
}
interface GetListsAction {
  type: typeof GET_LISTS;
}
interface GetListByIdAction {
  type: typeof GET_LIST_BY_ID;
  payload: string;
}
interface SetListIdToDeleteAction {
  type: typeof SET_LISTID_TO_DELETE;
  payload: string;
}
interface SetListToEditAction {
  type: typeof SET_LIST_TO_EDIT;
  payload: string;
}
interface DeleteListAction {
  type: typeof DELETE_LIST;
  payload: string;
}
interface UpdateListAction {
  type: typeof UPDATE_LIST;
  payload: {
    id: string;
    name: string;
  }
}  
interface SetSelectedListAction {
  type: typeof SET_SELECTED_LIST;
  payload: string;
}
interface SetNotificationAction {
  type: typeof SET_NOTIFICATION;
  payload: {
    msg: string;
    type: string;
  };
}
interface AddTaskAction {
  type: typeof ADD_TASK;
  payload: {
    task: Task;
    list: List;
  }
}
interface DeleteTaskAction {
  type: typeof DELETE_TASK;
  payload: {
    task: Task;
    list: List;
  };
}
interface SetTaskToDeleteAction {
  type: typeof SET_TASK_TO_DELETE;
  payload: {
    task: Task;
    list: List;
  };
}
interface UnsetTaskToDeleteAction {
  type: typeof UNSET_TASK_TO_DELETE;
}
interface EditTaskAction {
  type: typeof UPDATE_TASK;
  payload: {
    taskId: string;
    taskName: string;
    taskState: boolean;
    list: List;
  }
}
interface SetTaskToEditAction {
  type: typeof SET_TASK_TO_EDIT;
  payload: {
    task: Task;
    list: List;
  };
}
interface UnsetTaskToEditAction {
  type: typeof UNSET_TASK_TO_EDIT;
}
export type ListsAction = AddListAction | GetListsAction | GetListByIdAction | SetListIdToDeleteAction | SetListToEditAction | DeleteListAction | UpdateListAction | SetSelectedListAction | AddTaskAction | DeleteTaskAction | SetTaskToDeleteAction | UnsetTaskToDeleteAction | EditTaskAction | SetTaskToEditAction | UnsetTaskToEditAction;
export type NotificationAction = SetNotificationAction;

Как видите, действий у нас будет много. Каждый тип действия является строкой, но нам нужно использовать typeof, а затем тип, иначе typescript будет жаловаться и не будет знать, какой тип мы хотим использовать, потому что мы экспортируем все типы как тип объединения, и мы будем использовать только этот тип объединения в нашем действия.

И, наконец, давайте создадим интерфейсы для наших состояний. У нас будет 2 состояния, одно для списка и одно для уведомления.

export interface ListState {
  lists: Lists;
  listIdToDelete: string;
  listToEdit: List | null;
  listById: List | null;
  selectedList: List | null;
  taskToDelete: {
    task: Task;
    list: List;
  } | null;
  taskToEdit: {
    task: Task;
    list: List;
  } | null;
}
export interface NotificationState {
  message: string;
  type: string;
}

ListState будет иметь много свойств. У него будут списки, которые вернут все списки из локального хранилища, listIdToDelete, который будет установлен, когда мы нажмем кнопку удаления в нашем списке, и мы установим его до открытия модального окна, listToEdit, который будет установлен, когда мы нажмем имя списка, а затем мы можем использовать это в нашем модальном списке редактирования, selectedList, который изменится, когда мы изменим поле выбора, taskToDelete, который будет установлен, когда мы нажмем кнопку удаления задачи, и taskToEdit, который будет установлен, когда мы нажмем кнопку редактирования задачи.

NotificationState будет иметь только 2 свойства, сообщение и тип, и оба они являются строками.

Добавьте некоторые действия в наш магазин и управляйте состоянием

Внутри папки действий создайте файлы listActions.ts,notificationActions.ts и index.ts.

listActions.ts

import { 
  List, ListsAction, Task, ADD_LIST, GET_LISTS,  DELETE_LIST, SET_LISTID_TO_DELETE,
  GET_LIST_BY_ID, SET_LIST_TO_EDIT, UPDATE_LIST, SET_SELECTED_LIST, 
  ADD_TASK, SET_TASK_TO_DELETE, DELETE_TASK, UNSET_TASK_TO_DELETE, SET_TASK_TO_EDIT, 
  UNSET_TASK_TO_EDIT, UPDATE_TASK
} from '../types';
export const addList = (list: List): ListsAction => {
  return {
    type: ADD_LIST,
    payload: list
  }
}
export const getLists = (): ListsAction => {
  return {
    type: GET_LISTS
  }
}
export const getListById = (id: string): ListsAction => {
  return {
    type: GET_LIST_BY_ID,
    payload: id
  }
}
export const setListIdToDelete = (id: string): ListsAction => {
  return {
    type: SET_LISTID_TO_DELETE,
    payload: id
  }
}
export const setListToEdit = (id: string): ListsAction => {
  return {
    type: SET_LIST_TO_EDIT,
    payload: id
  }
}
export const setSelectedList = (id: string): ListsAction => {
  return {
    type: SET_SELECTED_LIST,
    payload: id
  }
}
export const deleteList = (id: string): ListsAction => {
  return {
    type: DELETE_LIST,
    payload: id
  }
}
export const updateList = (id: string, name: string): ListsAction => {
  return {
    type: UPDATE_LIST,
    payload: {
      id,
      name
    }
  }
}
export const addTask = (task: Task, list: List): ListsAction => {
  return {
    type: ADD_TASK,
    payload: {
      task,
      list
    }
  }
}
export const setTaskToDelete = (task: Task, list: List): ListsAction => {
  return {
    type: SET_TASK_TO_DELETE,
    payload: {
      task,
      list
    }
  }
}
export const unsetTaskToDelete = (): ListsAction => {
  return {
    type: UNSET_TASK_TO_DELETE
  }
}
export const deleteTask = (task: Task, list: List): ListsAction => {
  return {
    type: DELETE_TASK,
    payload: {
      task,
      list
    }
  }
}
export const setTaskToEdit = (task: Task, list: List): ListsAction => {
  return {
    type: SET_TASK_TO_EDIT,
    payload: {
      task,
      list
    }
  }
}
export const unsetTaskToEdit = (): ListsAction => {
  return {
    type: UNSET_TASK_TO_EDIT
  }
}
export const updateTask = (taskId: string, taskName: string, taskState: boolean, list: List): ListsAction => {
  return {
    type: UPDATE_TASK,
    payload: {
      taskId,
      taskName,
      taskState,
      list
    }
  }
}

Сначала нам нужно импортировать наши интерфейсы, тип объединения для действий списка и типы хранения. Затем мы можем создать действие, которое мы отправим в наши компоненты. Многие действия возвращают тип и полезную нагрузку, некоторые просто печатают. И все они вернут ListsAction, а поскольку мы использовали typeof, то машинописный текст будет знать, какой тип использовать для действия.

Например, в действии addList мы используем ListsAction в качестве возвращаемого типа, но поскольку мы используем ADD_LIST в качестве типа, машинописный текст будет знать, что мы хотим использовать AddListAction.

listReducer.ts

import { 
  ListState, ListsAction, Lists, ADD_LIST, GET_LISTS, 
  SET_LISTID_TO_DELETE, DELETE_LIST, GET_LIST_BY_ID, 
  SET_LIST_TO_EDIT, UPDATE_LIST, SET_SELECTED_LIST,
  ADD_TASK, DELETE_TASK, SET_TASK_TO_DELETE, UNSET_TASK_TO_DELETE, 
  SET_TASK_TO_EDIT, UNSET_TASK_TO_EDIT, UPDATE_TASK
} from '../types';
const initialState: ListState = {
  lists: {},
  listIdToDelete: '',
  listToEdit: null,
  listById: null,
  selectedList: null,
  taskToDelete: null,
  taskToEdit: null
}
const getListsFromLS = (): Lists => {
  if(localStorage.getItem('task_list')) {
    return JSON.parse(localStorage.getItem('task_list') || '{}');
  }
  return {};
}
const saveListsToLS = (lists: Lists) => {
  localStorage.setItem('task_list', JSON.stringify(lists));
}
export default (state = initialState, action: ListsAction): ListState => {
  const listsFromLS = getListsFromLS();
  
  switch(action.type) {
    case ADD_LIST:
      const copiedListsFromLS = {...listsFromLS};
      copiedListsFromLS[action.payload.id] = action.payload;
      saveListsToLS(copiedListsFromLS);
      return {
        ...state,
        lists: copiedListsFromLS
      };
    case GET_LISTS:
      return {
        ...state,
        lists: listsFromLS
      }
    case GET_LIST_BY_ID:
      const list = listsFromLS[action.payload];
      return {
        ...state,
        listById: list
      }
    case SET_LISTID_TO_DELETE:
      return {
        ...state,
        listIdToDelete: action.payload
      }
    case SET_LIST_TO_EDIT:
      const listToEdit = listsFromLS[action.payload] || null;
      return {
        ...state,
        listToEdit: listToEdit
      }
    case DELETE_LIST:
      const copiedListsFromLS2 = {...listsFromLS};
      const listId = copiedListsFromLS2[action.payload].id;
      delete copiedListsFromLS2[action.payload];
      saveListsToLS(copiedListsFromLS2);
      return {
        ...state,
        lists: copiedListsFromLS2,
        listIdToDelete: '',
        listById: null,
        selectedList: state.selectedList && listId === state.selectedList.id ? null : state.selectedList
      }
    case UPDATE_LIST:
      const copiedListsFromLS3 = {...listsFromLS};
      copiedListsFromLS3[action.payload.id].name = action.payload.name;
      saveListsToLS(copiedListsFromLS3);
      return {
        ...state,
        lists: copiedListsFromLS3,
        listToEdit: null
      }
    case SET_SELECTED_LIST:
      const selectedList = getListsFromLS()[action.payload];
      return {
        ...state,
        selectedList: selectedList
      }
    case ADD_TASK:
      const copiedListsFromLS4 = {...listsFromLS};
      copiedListsFromLS4[action.payload.list.id].tasks.push(action.payload.task);
      saveListsToLS(copiedListsFromLS4);
      return {
        ...state,
        lists: copiedListsFromLS4,
        selectedList: copiedListsFromLS4[action.payload.list.id]
      }
    case SET_TASK_TO_DELETE:
      return {
        ...state,
        taskToDelete: {
          task: action.payload.task,
          list: action.payload.list
        }
      }
    case UNSET_TASK_TO_DELETE:
      return {
        ...state,
        taskToDelete: null
      }
    case DELETE_TASK:
      const copiedListsFromLS5 = {...listsFromLS};
      const copiedTasks = [...copiedListsFromLS5[state.taskToDelete!.list.id].tasks];
      const task = copiedTasks.find(task => task.id === state.taskToDelete!.task.id);
      copiedTasks.splice(copiedTasks.indexOf(task!), 1);
      copiedListsFromLS5[state.taskToDelete!.list.id].tasks = copiedTasks;
      saveListsToLS(copiedListsFromLS5);
      return {
        ...state,
        lists: copiedListsFromLS5,
        selectedList: copiedListsFromLS5[state.taskToDelete!.list.id],
        taskToDelete: null
      }
    case SET_TASK_TO_EDIT:
      return {
        ...state,
        taskToEdit: {
          task: action.payload.task,
          list: action.payload.list
        }
      }
    case UNSET_TASK_TO_EDIT:
      return {
        ...state,
        taskToEdit: null
      }
    case UPDATE_TASK:
      const copiedListsFromLS6 = {...listsFromLS};
      const copiedList = {...copiedListsFromLS6[action.payload.list.id]};
      const copiedTasks2 = [...copiedList.tasks];
      const task2 = copiedTasks2.find((task) => task.id === action.payload.taskId);
      const copiedTask = {...task2!};
      copiedTask.name = action.payload.taskName;
      copiedTask.completed = action.payload.taskState;
      const updatedTasks = copiedTasks2.map(task => task.id === copiedTask.id ? copiedTask : task);
      copiedList.tasks = updatedTasks;
      copiedListsFromLS6[copiedList.id] = copiedList;
      saveListsToLS(copiedListsFromLS6);
      return {
        ...state,
        lists: copiedListsFromLS6,
        selectedList: copiedList,
        taskToEdit: null
      }
    default:
      return state;
  }
}

Сначала нам нужно импортировать некоторые интерфейсы и типы. Затем мы можем создать наше InitialState, которое будет иметь все свойства, которые мы определили в нашем интерфейсе ListState. И тогда наше состояние в редюсере может быть равно этому объекту initialState и действие может использовать тип ListsAction и редьюсер всегда будет возвращать ListState.

В этом файле я создал две вспомогательные функции: getListsFromLS, которая будет возвращать списки из локального хранилища или пустого объекта, и saveListsToLS, которая будет сохранять списки в локальное хранилище.

Теперь давайте добавим несколько случаев для каждого типа:

ADD_LIST

добавит список, который мы передали действию addList в качестве аргумента свойства lists, и сохранит его в локальном хранилище. Я всегда буду клонировать/копировать исходное значение, потому что я хочу изменить не исходное значение, а клонированное значение.

GET_LISTS

получит списки из локального хранилища и обновит свойство lists в состоянии списка

GET_LIST_BY_ID

получит список по id, который мы передали в нашем экшене в качестве параметра

SET_LISTID_TO_DELETE

установит listIdToDelete в идентификатор списка, который мы передали в качестве параметра

SET_LIST_TO_EDIT

получит список из локального хранилища по идентификатору, который мы передали в качестве параметра в нашем действии, и установит этот список в свойство listToEdit

DELETE_LIST

удалит список из локального хранилища, обновит свойство lists, установит listIdToDelete в пустую строку, установит для listById значение null и изменит selectedList на null, если список, который мы удалили, был выбран в поле выбора

ОБНОВЛЕНИЕ_СПИСКА

обновит список и сохранит обновленные списки в локальном хранилище, а также установит для listToEdit значение null

SET_SELECTED_LIST

обновит selectedList при изменении значения поля выбора

ADD_TASK

добавит новую задачу в выбранный список

SET_TASK_TO_DELETE

установит свойство задачи taskToDelete на задачу, которую мы хотим удалить, и свойство списка на выбранный список

UNSET_TASK_TO_DELETE

установит для taskToDelete значение null

DELETE_TASK

удалит выбранную задачу из выбранного списка и обновит списки в локальном хранилище

SET_TASK_TO_EDIT

установит свойство задачи taskToEdit для задачи, которую мы хотим отредактировать, и свойство list для выбранного списка

UNSET_TASK_TO_EDIT

установит для taskToEdit значение null

ОБНОВЛЕНИЕ_ТАСК

обновит выбранную задачу в выбранном списке и обновит списки в локальном хранилище

notificationActions.ts

import { SET_NOTIFICATION, NotificationAction } from '../types';
export const setNotification = (msg: string, type: string = 'success'): NotificationAction => {
  return {
    type: SET_NOTIFICATION,
    payload: {
      msg,
      type
    }
  }
}

У нас есть только одно действие в этом файле, и это действие будет отправляться каждый раз, когда мы добавляем, удаляем или редактируем задачу или список. Он имеет 2 параметра: msg — это сообщение, которое мы хотим отобразить, и тип, который будет использоваться для отображения зеленого или красного цвета фона уведомления.

notificationReducer.ts

import { SET_NOTIFICATION, NotificationAction, NotificationState } from '../types';
const initialState: NotificationState = {
  message: '',
  type: 'success'
}
export default (state = initialState, action: NotificationAction): NotificationState => {
  switch(action.type) {
    case SET_NOTIFICATION:
      return {
        ...state,
        message: action.payload.msg,
        type: action.payload.type
      }
    default:
      return state;
  }
}

Это очень просто. У него есть один случай, SET_NOTIFICATION, и он будет устанавливать/обновлять сообщение и тип состояния уведомления.

index.ts в папке действий

export * from './listActions';
export * from './notificationActions';

В этом файле мы просто хотим экспортировать все из listActions и notificationActions, чтобы мы всегда могли импортировать действия из этого файла в наши компоненты.

Часть вторая