В этом уроке мы продолжим работу и усовершенствуем наше предыдущее приложение. Мы расширим наш store и добавим немного функциональности, разберемся с пакетом redux-toolkit.

По ходу урока мы добавим к нашему приложению категории, сделаем фильтрацию тасков, изменим структуру файлов и перепишем экшены и редюсеры с помощью redux-toolkit.

Ссылка на прошлый проект:

Новое приложение будет выглядеть вот так — https://codesandbox.io/s/github/web-devguru/redux-toolkit-example (ссылки на исходники внизу поста)

Готовое приложение

Структура папок и файлов для Redux Store

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

Ducks Pattern

Этот вариант предлагает хранение экшенов и редюсеров в отдельных файлах по типу, к чему они относятся. Например, то что мы писали в прошлый раз можно сохранить в один файл todos.js и определить в нем сразу и экшены и редюсер. В таком случае структура выглядела бы так:

Ducks Pattern

сategories.js — отвечает за экшены и редюсер для категорий
tasks.js — отвечает за экшены и редюсер для тасков
сonfigureStore.js — отвечает за создание стора
entities.js — этот файл будет объединять categories и todos в одну запись в store
rootReducer.js — в этом файле объединяются 2 редюсера (категории и таски) в один

Feture Folder

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

Мы будем строить приложение используя этот паттерн. В таком случае наша часть приложения, отвечающая за store, будет выглядеть следующим образом:

Структура файлов нашего store

Так в каждой папке находятся actions/reducer отвечающие за эту часть нашего хранилища.

Файлы entities.js и filtersReducers.js будут отвечать за объединение тасков/категорий и фильтров соответственно в отдельные записи в store. В rootReducer мы потом объединим их оба.

Как и говорил в начале, мы добавим в наше приложение немного новой функциональности. В следующих разделах мы постепенно будем разбираться какие инструменты нам дает redux-toolkit и будем переделывать наше приложение.

Подготовка проекта

Так как наш проект будет постепенно становиться больше, давайте создадим в папке components отдельную папку для каждого компонента и файл index.js, который будет отвечать только за экспорт нашего компонента. Вот так будет выглядеть структура нашего приложения:

Структура нашего проекта

На примере, компонента AddNewTask файл index.js будет выглядеть так:

export { default } from './AddNewTask';

Мы просто экспортируем по дефолту то, что было экспортировано из файла AddNewTask.js. Это делается для того, чтобы если вы решите изменить экспорт или имя компонента вам не приходилось его везде менять. У вас в нужном месте импорт останется тот же, из файла index.js.

Также и для store давайте создадим папки: categories, categoryFilter, statusFilter, tasks. В каждой папке будет 2 файла — actions.js и reducer.js

Файлы в папке store можно удалить — actions.js, actionsTypes.js, reducer.js

Вам нужно сделать такие же папки и файлы в своем приложении, также не забудьте изменить путь там где эти модули импортируются. Или можете скачать уже готовое приложение по ссылкам в конце поста.

Дальше по ходу урока мы изменим эти файлы.

Redux Toolkit

Данная библиотека позволяет упростить работу с созданием экшенов, редюсеров, позволяет комбинировать несколько редюсеров… в общем упрощает работу разработчика с Redux (документация).

Инструменты, которые нам предлагает redux-toolkit

  • createAction()
  • createReducer()
  • createSlice()
  • combineReducers()
  • configureStore()

Для установки этого пакета мы можем воспользоваться следующей командой:

# NPM
npm install @reduxjs/toolkit

# Yarn
yarn add @reduxjs/toolkit

createAction()

В прошлом посте мы рассматривали как создаются экшены при использовании редакса — мы определяли типы экшенов, потом создавали функцию и возвращали из нее объект со свойствами {type: «», payload: «»}

При использовании redux-toolkit, мы можем создать экшен проще. В файле src/store/tasks/actions.js заменим код на:

import { createAction } from "@reduxjs/toolkit";

export const addTask = createAction("TASK_ADD");
export const toggleTask = createAction("TASK_TOGGLE");
export const removeTask = createAction("TASK_REMOVE");

В качестве параметра для функции createAction нам необходимо передать текст, который будет использоваться в качестве type.

Таким же образом давайте создадим экшены для наших категорий. В файле src/store/categories/actions.js добавим следующий код:

import { createAction } from '@reduxjs/toolkit';

export const addCategory = createAction('ADD_CATEGORY');

Также создадим экшены для наших будущих фильтров (по статусу и по категории таска).

Сейчас мы добавим код для экшенов, а в следующем разделе добавим редюсеры.

src/store/categoryFilter/actions.js

import { createAction } from "@reduxjs/toolkit";

export const setCategoryFilter = createAction('SET_CATEGORY_FILTER');

categoryFilters.SHOW_ALL мы передаем как значение по умолчанию. Ноль указываем, так как в дальнейшем будем передавать в редюсер id категории, чтобы везде было цифровое значение.

src/store/statusFilter/actions.js

import { createAction } from "@reduxjs/toolkit";

export const setStatusFilter = createAction('SET_STATUS_FILTER');

createReducer()

Этот инструмент упрощает нам работу с созданием редюсеров. Первое и самое основное это то, что тут можно не переживать по поводу «мутабельности» вашего кода, т.е. вы можете изменять state напрямую, а дальше createReducer() сделает всё за вас.

Файл src/store/tasks/reducer.js будет таким

import { createReducer } from '@reduxjs/toolkit';
import { addTask, toggleTask, removeTask } from './actions';

let lastId = 0;

export default createReducer([], {
  [addTask.type]: (tasks, action) => {
    const { category, title } = action.payload;

    tasks.push({
      id: ++lastId,
      title,
      category,
      completed: false
    })
  },
  [toggleTask.type]: (tasks, action) => {
    const index = tasks.findIndex(task => task.id === action.payload.id);
    tasks[index].completed = !tasks[index].completed;
  },
  [removeTask.type]: (tasks, action) => {
    const index = tasks.findIndex(task => task.id === action.payload.id);
    tasks.splice(index, 1);
  }
})

createReducer() принимает 2 параметра: дефолтное состояние стора, в нашем случае это пустой массив, и объект с имеющимися экшенами и колбеком. Колбэк также принимает текущее состояние и action в качестве параметров.

Так как мы создавали экшены через createAction(), теперь мы можем использовать их как в коде выше — [addTask.type] (будет подставлено, то значение, которое мы передавали как параметр для createAction(«TASK_ADD»))

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

Файл src/store/categories/reducer.js

import { createReducer } from "@reduxjs/toolkit";
import { addCategory } from './actions';

let lastId = 3;

const defaultCategories = [
  { id: 1, title: 'Дом' },
  { id: 2, title: 'Работа' },
  { id: 3, title: 'Список Покупок' }
]

export default createReducer(defaultCategories, {
  [addCategory.type]: (categories, action) => {
    categories.push({
      id: ++lastId,
      title: action.payload.title
    })
  }
});

Как вы видите тут мы в качестве значения по умолчанию передаем массив категорий — defaultCategories, а также lastId равен 3, т.к. у нас уже есть 3 категории и при добавлении новой нумерация продолжится с 4.

Теперь добавим код для фильтров.

src/store/categoryFilter/reducer.js

import { createReducer } from "@reduxjs/toolkit";
import { setCategoryFilter } from "./actions";

export const categoryFilters = {
  SHOW_ALL: 0
}

export default createReducer(categoryFilters.SHOW_ALL, {
  [setCategoryFilter.type]: (filters, action) => {
    return action.payload;
  }
})

src/store/statusFilter/reducer.js

import { createReducer } from "@reduxjs/toolkit";
import { setStatusFilter } from "./actions";

export const statusFilters = {
  SHOW_ALL: 'SHOW_ALL',
  SHOW_COMPLETED: 'SHOW_COMPLETED',
  SHOW_ACTIVE: 'SHOW_ACTIVE'
}

export default createReducer(statusFilters.SHOW_ALL, {
  [setStatusFilter.type]: (filters, action) => {
    return action.payload;
  }
})

createSlice()

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

В случае с нашими ToDo мы бы сделали вот так:

import { createSlice } from "@reduxjs/toolkit";

const slice = createSlice({
  name: 'tasks', // название
  initialState: [], // дефолтное состояние
  reducers: { // тут мы просто для каждого экшена указываем колбек
    addTask: (tasks, action) => {
      tasks.push({
        id: ++lastId,
        title: action.payload.title,
        completed: false
      })
    }
    // Тут будет обработка остальных экшенов...
  }
});

export const { addTask } = slice.actions; // экспортируем созданные экшены
export default slice.reducer; // экспортируем редюсер

combineReducers()

Т.к. у нас уже есть 2 редюсера, нам теперь их нужно скомбинировать. Для этого мы воспользуемся инструментом combineReducers().

Создаем в папке store три файла — entities.js, filtersReducer.js, rootReducer.js

В файл entities.js с добавим следующий код:

import { combineReducers } from 'redux';
import tasksReducer from './tasks/reducer';
import categoriesReducer from './categories/reducer';

export default combineReducers({
  tasks: tasksReducer,
  categories: categoriesReducer
});

Теперь нужно объединить фильтры в отдельный кусок в стор. Для этого создадим файл src/store/filtersReducer.js

import { combineReducers } from "redux";
import statusFilterReducer from './statusFilter/reducer';
import categoryFilterReducer from './categoryFilter/reducer';

export default combineReducers({
  byStatus: statusFilterReducer,
  byCategory: categoryFilterReducer
})

Давайте создадим еще файл rootReducer.js, который будет отвечать за объединение всех существующих редюсеров:

import { combineReducers } from 'redux';
import entitiesReducer from "./entities";
import filtersReducer from "./filtersReducer";

export default combineReducers({
  entities: entitiesReducer,
  filters: filtersReducer
})

Теперь в нашем store будет отдельная запись entities, где будут хранится категории и таски, а также filters, где будут хранится фильтры выбранные пользователем:

Структура Redux Store

configureStore()

В прошлом уроке мы использовали createStore() функцию для создания нашего хранилища. Также мы добавляли еще один параметр для корректной работы плагина Redux DevTools — плагина для браузера, это была вот такая строка:

window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()

При использовании configureStore() нам эта строка не понадобится. Чтобы использовать этот инструмент, нам нужно его импортировать

import { configureStore } from "@reduxjs/toolkit";
import reducer from './rootReducer';

export default configureStore({ reducer });

Создание новых компонентов

Теперь нам нужно добавить новые компоненты. В src/components создаем папки AddNewCategory, CategoriesList, Category. Теперь поочередно добавим в них нужный код.

src/components/AddNewCategory/AddNewCategory.js

import React, { useState } from 'react';
import { Button, FormControl, InputGroup } from "react-bootstrap";
import { useDispatch } from "react-redux";
import * as actions from './../../store/categories/actions';

const AddNewCategory = () => {

  const dispatch = useDispatch();

  const [showInput, setShowInput] = useState(false);
  const [categoryTitle, setCategoryTitle] = useState('');

  const handleChange = (e) => {
    setCategoryTitle(e.target.value);
  }

  const handleSubmit = () => {
    dispatch(actions.addCategory({ title: categoryTitle }));
    setCategoryTitle('');
  }

  return (
    <div className="add-new-category">
      {showInput &&
        <InputGroup className="mb-3">
          <FormControl
            value={categoryTitle}
            placeholder="Название"
            onChange={handleChange}
          />
          <InputGroup.Append>
            <Button
              onClick={ handleSubmit }
              variant="outline-secondary"
              disabled={!categoryTitle}
            >
              +
            </Button>
          </InputGroup.Append>
        </InputGroup>
      }
      + <span onClick={() => setShowInput(!showInput)}>
          { showInput ? 'Скрыть' : 'Добавить категорию' }
        </span>
    </div>
  );
}

export default AddNewCategory;

src/components/AddNewCategory/index.js

export { default } from './AddNewCategory';

src/components/CategoriesList/CategoriesList.js

import React, { useState } from 'react';
import { useDispatch, useSelector } from "react-redux";
import { ListGroup } from "react-bootstrap";
import { setCategoryFilter } from "../../store/categoryFilter/actions";
import { categoryFilters } from '../../store/categoryFilter/reducer';

const CategoriesList = () => {
  const dispatch = useDispatch();

  // Я сюда перенес получение из store наших категорий, т.к. они у нас не используются в файле App.js
  // И нам не нужно теперь передавать их через props
  const { categories } = useSelector(state => state.entities);

  const [activeTab, setActiveTab] = useState(0)

  const handleCategoryClick = (e) => {
    setActiveTab(+e.target.value);
    dispatch(setCategoryFilter(+e.target.value));
  }

  return (
    <ListGroup>
      <ListGroup.Item action value={ categoryFilters.SHOW_ALL } onClick={e => handleCategoryClick(e)} active={activeTab === 0}>
        Все категории
      </ListGroup.Item>
      {
        categories.map((category, index) =>
        <ListGroup.Item key={index} action onClick={ e => handleCategoryClick(e) } value={ category.id } active={activeTab === category.id}>
          { category.title }
        </ListGroup.Item>)
      }
    </ListGroup>
  )
}

export default CategoriesList;

src/components/CategoriesList/index.js

export { default } from './CategoriesList';

Следующим этапом нам нужно создать компонент для фильтрации по статусу. Создадим в папке componetns папку StatusFilter и в ней 2 файла — index.js и StatusFilter.js

StatusFilter.js

import React, { useState } from 'react';
import { useDispatch } from "react-redux";
import { ToggleButtonGroup, ToggleButton } from 'react-bootstrap';
import { setStatusFilter } from "../../store/statusFilter/actions";
import { statusFilters } from '../../store/statusFilter/reducer';

const StatusFilter = () => {
  const dispatch = useDispatch();

  const handleChange = val => {
    dispatch(setStatusFilter(val))
  }

  return (
    <div className='text-right'>
      <ToggleButtonGroup
        onChange={handleChange}
        defaultValue={ statusFilters.SHOW_ALL }
        type="radio"
        name="filters"
        size="sm"
      >
        <ToggleButton
          value={ statusFilters.SHOW_ALL }
          variant="light"
        >
          Все
        </ToggleButton>
        <ToggleButton
          value={ statusFilters.SHOW_ACTIVE }
          variant="light"
        >
          Активные
        </ToggleButton>
        <ToggleButton
          value={ statusFilters.SHOW_COMPLETED }
          variant="light"
        >
          Выполненные
        </ToggleButton>
      </ToggleButtonGroup>
    </div>
  )
}

export default StatusFilter;

index.js

export { default } from './StatusFilter';

Изменение старых компонентов

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

В первую очередь нужно все файлы перенести в соответствующие папки, добавить файл index.js с аналогичным содержимым как мы сделали для категорий.

В src/components/Task/Task.js нужно изменить метод dispatch и передать туда объект со свойством id и значением id. Это нужно изменить на 11 строке и 20-й:

// это короткая запись использующая синтаксис ES6
// полная была бы { id: id }
dispatch(toggleTask({ id })) 

Также изменим импорт экшенов в AddNewTask на строке 3 на такой:

import { addTask } from "../../store/tasks/actions";

А метод dispatch:

const handleTaskSubmit = () => {
    dispatch(addTask({
      title: taskTitle
    }));
    setTaskTitle('');
  }

Т.к. у нас изменилась структура стора, добавились новые компоненты, нам нужно изменить и файл App.js

import React from 'react';
import { Container, Col, Row } from "react-bootstrap";
import TaskList from "./components/TaskList/TaskList";
import AddNewTask from "./components/AddNewTask/AddNewTask";
import { useSelector } from "react-redux";
import './App.css';
import CategoriesList from "./components/CatagoriesList";
import AddNewCategory from "./components/AddNewCategory";
import StatusFilter from './components/StatusFilter';
import { selectVisibleTasks } from './store/selectors';

function App() {

  const state = useSelector(state => state);

  const tasks = selectVisibleTasks(state);

  return (
    <Container className="main-app-container">
      <Row className="mb-4">
        <Col className="mt-4">
          <AddNewTask />
        </Col>
      </Row>
      <Row>
        <Col md={12}>
          <Row className="align-items-center">
            <Col md={6}>
              <h4 className="mb-0">Список задач</h4>
            </Col>
            <Col md={6}>
              <StatusFilter />
            </Col>
          </Row>
          <hr/>
        </Col>
        <Col md={3}>
          <CategoriesList />
          <AddNewCategory />
        </Col>
        <Col>
          {tasks.length
            ? <TaskList tasks={ tasks } />
            : 'У вас пока нет задач. Воспользуйтесь формой выше для добавления задачи.'
          }
        </Col>
      </Row>
    </Container>
  );
}

export default App;

В этом файле мы убрали получение категорий, мы раньше перенесли его в файл CategoriesList.js, также добавили селектор для выборки тасков для отображения. Что такое селекторы мы рассмотрим ниже.

Изменения стилей

Я изменил стили и добавил в конце файла App.css:

.add-new-category {
  margin-top: 10px;
  text-align: center;
  color: #566573;
}

.add-new-category span {
  display: inline-block;
  border-bottom: 1px dashed;
}

.add-new-category span:hover {
  border-bottom: none;
  cursor: pointer;
  color: #212529;
}

Selectors (селекторы)

Селекторы это простые функции, которые возвращают, в нашем случае из стора, нужную нам информацию. К примеру нам нужно выбрать из стора все таски, для этого мы пишем функцию, которая на вход получит весь стор, а вернет нам только tasks.

В redux toolkit есть своя функция (точнее она используется из библиотеки Reselect), которая в добавок к этому делает Memoization (не знаю, как даже правильно это будет на русском, lol). Т.е. результат кешируется и в дальнейшем если в сторе не было изменений, то будут использованы данные из памяти.

Для нашего приложения нам пока нужен будет только один селектор, для выбора тасков с учетом фильтрации (по статусу и категории). Создадим файл в src/store/selectors.js

import { createSelector } from "@reduxjs/toolkit";
import { statusFilters } from "./statusFilter/reducer";
import { categoryFilters } from './categoryFilter/reducer';

const selectTasks = state => state.entities.tasks;
const filters = state => state.filters;

export const selectVisibleTasks = createSelector(
  [selectTasks, filters],
  (tasks, filter) => {
    switch (filter.byStatus) {
      case statusFilters.SHOW_ALL:
        // Выбираем что вернуть, если по статусу выбраны "Все" и выбрана определенная категория
        return (filter.byCategory !== categoryFilters.SHOW_ALL)
          ? tasks.filter(task => task.category === filter.byCategory)
          : tasks;

      case statusFilters.SHOW_ACTIVE:
        // Выбираем что вернуть, если по статусу выбраны "Активные" и выбрана определенная категория
        return tasks.filter(task => {
          return (filter.byCategory !== categoryFilters.SHOW_ALL)
            ? (task.category === filter.byCategory) && !task.completed
            : !task.completed
        })

      case statusFilters.SHOW_COMPLETED:
        // Выбираем что вернуть, если по статусу выбраны "Выполненные" и выбрана определенная категория
        return tasks.filter(task => {
          return (filter.byCategory !== categoryFilters.SHOW_ALL)
            ? (task.category === filter.byCategory) && task.completed
            : task.completed
        })

      default:
        throw new Error("Неизвестный фильтр: " + filter);
    }
  }
)

Готовое приложение и ссылки на его скачивание:

Вид приложение Tasks

Ваши вопросы и комментарии: