Содержание
В этом уроке мы продолжим работу и усовершенствуем наше предыдущее приложение. Мы расширим наш 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); } } )
Готовое приложение и ссылки на его скачивание: