Содержание
В этом уроке мы продолжим работу и усовершенствуем наше предыдущее приложение. Мы расширим наш store и добавим немного функциональности, разберемся с пакетом redux-toolkit.
По ходу урока мы добавим к нашему приложению категории, сделаем фильтрацию тасков, изменим структуру файлов и перепишем экшены и редюсеры с помощью redux-toolkit.
Ссылка на прошлый проект:
Новое приложение будет выглядеть вот так — https://codesandbox.io/s/github/web-devguru/redux-toolkit-example (ссылки на исходники внизу поста)

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

сategories.js — отвечает за экшены и редюсер для категорий
tasks.js — отвечает за экшены и редюсер для тасков
сonfigureStore.js — отвечает за создание стора
entities.js — этот файл будет объединять categories и todos в одну запись в store
rootReducer.js — в этом файле объединяются 2 редюсера (категории и таски) в один
Feture Folder
Данный подход предполагает для каждой отдельной функции создавать отдельную папку и в нее помещать отдельно actions, reducers.
Мы будем строить приложение используя этот паттерн. В таком случае наша часть приложения, отвечающая за 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, где будут хранится фильтры выбранные пользователем:

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);
}
}
)
Готовое приложение и ссылки на его скачивание:

Добавить комментарий